fix(web): trim redundant and incorrect tooltips (#1316)

* fix(web): trim redundant and incorrect tooltips

Drop hover tooltips that only restated a button's accessible label, and remove the permission-pill tooltip that described cycling modes while the control actually opens a dropdown. Keep tooltips that reveal truncated text or explain status.

* fix(web): remove remaining ChatHeader tooltips

Drop the session-title, git-branch, and open-PR tooltips in ChatHeader so the header has no hover tooltips. Broaden the changeset wording to cover the wider trim.

* fix(web): keep tooltips from getting stuck on unmount

Tooltip attached its mouseleave listener to the slotted trigger element once, so if that element was removed (e.g. a v-if toggled while hovering a tool-call path) the open bubble never received mouseleave and stranded on screen. Re-sync the listener to the live slotted element via a MutationObserver and close the tooltip whenever the trigger changes.

* chore: add changeset for stuck-tooltip fix

* fix(web): restore session title tooltip in ChatHeader

The session title is truncated with an ellipsis when it exceeds the header width, so the tooltip was the only way to read the full name from the header. Restoring it keeps the truncation-revealing hint while the redundant git/branch/open-PR tooltips stay removed.
This commit is contained in:
qer 2026-07-02 19:53:01 +08:00 committed by GitHub
parent 78a058acd2
commit 5322c63889
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 353 additions and 406 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix web tooltips that could get stuck on screen when their trigger element is removed while open.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Trim redundant and incorrect tooltips in the web UI.

View file

@ -40,7 +40,6 @@ import type { AppConfig, ThinkingLevel } from './api/types';
import Button from './components/ui/Button.vue';
import IconButton from './components/ui/IconButton.vue';
import Icon from './components/ui/Icon.vue';
import Tooltip from './components/ui/Tooltip.vue';
// Hydrate the server-transport credential (fragment token or sessionStorage)
// BEFORE the client connects, so the first REST/WS calls already carry it.
@ -625,15 +624,13 @@ function openPr(url: string): void {
@update:width="sessionColWidth = $event"
/>
<div v-if="sidebarCollapsed" class="sidebar-rail">
<Tooltip :text="t('sidebar.expandSidebar')">
<IconButton
size="sm"
:label="t('sidebar.expandSidebar')"
@click="toggleSidebarCollapse"
>
<Icon name="panel-expand" size="sm" />
</IconButton>
</Tooltip>
<IconButton
size="sm"
:label="t('sidebar.expandSidebar')"
@click="toggleSidebarCollapse"
>
<Icon name="panel-expand" size="sm" />
</IconButton>
</div>
</template>

View file

@ -482,60 +482,46 @@ function truncatePath(path: string, maxLen = 55): string {
<span v-if="searchQuery.trim()" class="fp-search-count">
{{ searchMatches.length }}
</span>
<Tooltip :text="t('filePreview.prevMatch')">
<IconButton size="sm" :disabled="searchMatches.length === 0" :label="t('filePreview.prevMatch')" @click="nextMatch(-1)">
<Icon name="arrow-up" size="md" />
</IconButton>
</Tooltip>
<Tooltip :text="t('filePreview.nextMatch')">
<IconButton size="sm" :disabled="searchMatches.length === 0" :label="t('filePreview.nextMatch')" @click="nextMatch(1)">
<Icon name="arrow-down" size="md" />
</IconButton>
</Tooltip>
<IconButton size="sm" :disabled="searchMatches.length === 0" :label="t('filePreview.prevMatch')" @click="nextMatch(-1)">
<Icon name="arrow-up" size="md" />
</IconButton>
<IconButton size="sm" :disabled="searchMatches.length === 0" :label="t('filePreview.nextMatch')" @click="nextMatch(1)">
<Icon name="arrow-down" size="md" />
</IconButton>
</div>
<!-- Icon actions: text labels made the header wrap to two rows at the
default panel width icons + title tooltips keep it single-line. -->
<Tooltip :text="copiedPath ? t('filePreview.copied') : t('filePreview.copyPath')">
<IconButton size="sm" :class="{ copied: copiedPath }" :label="copiedPath ? t('filePreview.copied') : t('filePreview.copyPath')" @click="copyPath">
<Icon v-if="!copiedPath" name="link" size="md" />
<Icon v-else class="fp-check" name="check" size="md" />
</IconButton>
</Tooltip>
<Tooltip :text="t('filePreview.openInEditor')">
<IconButton v-if="externalActions" size="sm" :label="t('filePreview.openInEditor')" @click="emit('openExternal')">
<Icon name="external-link" size="md" />
</IconButton>
</Tooltip>
<Tooltip :text="t('filePreview.reveal')">
<IconButton v-if="externalActions" size="sm" :label="t('filePreview.reveal')" @click="emit('reveal')">
<Icon name="folder" size="md" />
</IconButton>
</Tooltip>
<Tooltip :text="t('filePreview.download')">
<a
v-if="downloadUrl"
class="fp-download"
:href="downloadUrl"
target="_blank"
rel="noreferrer"
download
:aria-label="t('filePreview.download')"
>
<Icon name="download" size="md" />
</a>
</Tooltip>
<Tooltip :text="copied ? t('filePreview.copied') : t('filePreview.copy')">
<IconButton
v-if="!file.isBinary && contentKind !== 'image'"
size="sm"
:class="{ copied }"
:label="copied ? t('filePreview.copied') : t('filePreview.copy')"
@click="copyContent"
>
<Icon v-if="!copied" name="copy" size="md" />
<Icon v-else class="fp-check" name="check" size="md" />
</IconButton>
</Tooltip>
default panel width icon-only buttons keep it single-line. -->
<IconButton size="sm" :class="{ copied: copiedPath }" :label="copiedPath ? t('filePreview.copied') : t('filePreview.copyPath')" @click="copyPath">
<Icon v-if="!copiedPath" name="link" size="md" />
<Icon v-else class="fp-check" name="check" size="md" />
</IconButton>
<IconButton v-if="externalActions" size="sm" :label="t('filePreview.openInEditor')" @click="emit('openExternal')">
<Icon name="external-link" size="md" />
</IconButton>
<IconButton v-if="externalActions" size="sm" :label="t('filePreview.reveal')" @click="emit('reveal')">
<Icon name="folder" size="md" />
</IconButton>
<a
v-if="downloadUrl"
class="fp-download"
:href="downloadUrl"
target="_blank"
rel="noreferrer"
download
:aria-label="t('filePreview.download')"
>
<Icon name="download" size="md" />
</a>
<IconButton
v-if="!file.isBinary && contentKind !== 'image'"
size="sm"
:class="{ copied }"
:label="copied ? t('filePreview.copied') : t('filePreview.copy')"
@click="copyContent"
>
<Icon v-if="!copied" name="copy" size="md" />
<Icon v-else class="fp-check" name="check" size="md" />
</IconButton>
</PanelHeader>
<!-- Body: Markdown -->

View file

@ -23,7 +23,6 @@ import IconButton from './ui/IconButton.vue';
import Icon from './ui/Icon.vue';
import Menu from './ui/Menu.vue';
import MenuItem from './ui/MenuItem.vue';
import Tooltip from './ui/Tooltip.vue';
import { useConfirmDialog } from '../composables/useConfirmDialog';
const { t } = useI18n();
@ -553,24 +552,20 @@ onBeforeUnmount(() => {
<span class="ch-name">Kimi Code<span v-if="isDev" class="ch-endpoint"> · {{ endpoint }}</span></span>
<InternalBuildBanner />
</div>
<Tooltip :text="t('sidebar.collapseSidebar')">
<IconButton
size="sm"
:label="t('sidebar.collapseSidebar')"
@click.stop="emit('collapse')"
>
<Icon name="panel-collapse" />
</IconButton>
</Tooltip>
<Tooltip :text="t('settings.title')">
<IconButton
size="sm"
:label="t('settings.title')"
@click.stop="emit('openSettings')"
>
<Icon name="settings" />
</IconButton>
</Tooltip>
<IconButton
size="sm"
:label="t('sidebar.collapseSidebar')"
@click.stop="emit('collapse')"
>
<Icon name="panel-collapse" />
</IconButton>
<IconButton
size="sm"
:label="t('settings.title')"
@click.stop="emit('openSettings')"
>
<Icon name="settings" />
</IconButton>
</div>
<!-- Session search opens the Spotlight-style search dialog -->
@ -585,16 +580,14 @@ onBeforeUnmount(() => {
<Icon name="chat-new" />
<span>{{ t('sidebar.newChat') }}</span>
</button>
<Tooltip :text="t('sidebar.newWorkspace')">
<IconButton
v-if="showNewWorkspaceButton"
size="sm"
:label="t('sidebar.newWorkspace')"
@click.stop="emit('addWorkspace')"
>
<Icon name="folder" />
</IconButton>
</Tooltip>
<IconButton
v-if="showNewWorkspaceButton"
size="sm"
:label="t('sidebar.newWorkspace')"
@click.stop="emit('addWorkspace')"
>
<Icon name="folder" />
</IconButton>
</div>
<!-- Session list grouped by workspace -->
@ -609,29 +602,25 @@ onBeforeUnmount(() => {
<div class="side-section-label">
<span class="side-section-title">{{ t('sidebar.workspaces') }}</span>
<div class="side-section-actions">
<Tooltip :text="allCollapsed ? t('sidebar.expandAll') : t('sidebar.collapseAll')">
<IconButton
class="side-section-toggle"
size="sm"
:label="allCollapsed ? t('sidebar.expandAll') : t('sidebar.collapseAll')"
@click.stop="allCollapsed ? expandAllWorkspaces() : collapseAllWorkspaces()"
>
<Icon v-if="allCollapsed" name="expand" />
<Icon v-else name="collapse" />
</IconButton>
</Tooltip>
<Tooltip :text="t('sidebar.options')">
<IconButton
class="side-section-toggle side-section-kebab"
size="sm"
:label="t('sidebar.options')"
aria-haspopup="menu"
:aria-expanded="sectionMenuOpen"
@click.stop="toggleSectionMenu($event)"
>
<Icon name="dots-horizontal" />
</IconButton>
</Tooltip>
<IconButton
class="side-section-toggle"
size="sm"
:label="allCollapsed ? t('sidebar.expandAll') : t('sidebar.collapseAll')"
@click.stop="allCollapsed ? expandAllWorkspaces() : collapseAllWorkspaces()"
>
<Icon v-if="allCollapsed" name="expand" />
<Icon v-else name="collapse" />
</IconButton>
<IconButton
class="side-section-toggle side-section-kebab"
size="sm"
:label="t('sidebar.options')"
aria-haspopup="menu"
:aria-expanded="sectionMenuOpen"
@click.stop="toggleSectionMenu($event)"
>
<Icon name="dots-horizontal" />
</IconButton>
</div>
</div>
<div

View file

@ -108,30 +108,26 @@ function onHeaderDragStart(event: DragEvent): void {
@click.stop
/>
<Tooltip :text="t('sidebar.options')">
<IconButton
class="gh-more"
:class="{ open: wsMenuOpenId === group.workspace.id }"
size="sm"
:label="t('sidebar.options')"
aria-haspopup="menu"
:aria-expanded="wsMenuOpenId === group.workspace.id"
@click.stop="emit('toggleWsMenu', group.workspace, $event)"
>
<Icon name="dots-horizontal" size="sm" />
</IconButton>
</Tooltip>
<IconButton
class="gh-more"
:class="{ open: wsMenuOpenId === group.workspace.id }"
size="sm"
:label="t('sidebar.options')"
aria-haspopup="menu"
:aria-expanded="wsMenuOpenId === group.workspace.id"
@click.stop="emit('toggleWsMenu', group.workspace, $event)"
>
<Icon name="dots-horizontal" size="sm" />
</IconButton>
<Tooltip :text="t('workspace.newInGroup')">
<IconButton
class="gh-add"
size="sm"
:label="t('workspace.newInGroup')"
@click.stop="emit('createInWorkspace', group.workspace.id)"
>
<Icon name="chat-new" />
</IconButton>
</Tooltip>
<IconButton
class="gh-add"
size="sm"
:label="t('workspace.newInGroup')"
@click.stop="emit('createInWorkspace', group.workspace.id)"
>
<Icon name="chat-new" />
</IconButton>
</div>
<Tooltip :text="group.workspace.root">

View file

@ -191,17 +191,15 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
</span>
<Badge v-if="agentName && !minimized" variant="neutral" size="sm">{{ t('approval.subagentBadge', { name: agentName }) }}</Badge>
<Badge v-if="!minimized" variant="warning" size="sm" class="aw">{{ t('approval.required') }}</Badge>
<Tooltip :text="minimized ? t('question.expand') : t('question.minimize')">
<IconButton
class="amin"
size="sm"
:label="minimized ? t('question.expand') : t('question.minimize')"
@click="minimized = !minimized"
>
<Icon v-if="minimized" name="chevron-down" size="md" />
<Icon v-else name="minus" size="md" />
</IconButton>
</Tooltip>
<IconButton
class="amin"
size="sm"
:label="minimized ? t('question.expand') : t('question.minimize')"
@click="minimized = !minimized"
>
<Icon v-if="minimized" name="chevron-down" size="md" />
<Icon v-else name="minus" size="md" />
</IconButton>
</div>
</template>

View file

@ -222,19 +222,17 @@ async function startArchive(): Promise<void> {
</div>
<!-- More menu trigger: copy-all + session actions -->
<Tooltip :text="t('header.options')">
<IconButton
ref="kebabRef"
class="ch-act-more"
:class="{ open: menuOpen }"
:label="t('header.options')"
:aria-expanded="menuOpen"
aria-haspopup="menu"
@click.stop="toggleMenu($event)"
>
<Icon name="dots-horizontal" size="md" />
</IconButton>
</Tooltip>
<IconButton
ref="kebabRef"
class="ch-act-more"
:class="{ open: menuOpen }"
:label="t('header.options')"
:aria-expanded="menuOpen"
aria-haspopup="menu"
@click.stop="toggleMenu($event)"
>
<Icon name="dots-horizontal" size="md" />
</IconButton>
<!-- Fixed more menu -->
<Menu
@ -270,45 +268,39 @@ async function startArchive(): Promise<void> {
<!-- Git branch + status plain text with semantic colors. Renders for any
git repo, even a detached HEAD (empty branch "detached" label), so the
diff counter below is never hidden just because there's no branch name. -->
<Tooltip :text="t('header.gitTooltip')">
<button
v-if="isGitRepo"
type="button"
class="ch-git"
@click="emit('openChanges')"
<button
v-if="isGitRepo"
type="button"
class="ch-git"
@click="emit('openChanges')"
>
<span
class="ch-branch"
:class="{ 'ch-detached': !branch }"
>
<Tooltip :text="branch || t('header.detached')">
<span
class="ch-branch"
:class="{ 'ch-detached': !branch }"
>
{{ branch || t('header.detached') }}
</span>
</Tooltip>
<span v-if="ahead > 0 || behind > 0" class="ch-pill ch-sync-pill">
<span v-if="ahead > 0" class="ch-ahead">{{ ahead }}</span>
<span v-if="behind > 0" class="ch-behind">{{ behind }}</span>
</span>
<span v-if="hasLineStats" class="ch-pill ch-diff-pill">
<span v-if="adds > 0" class="ch-add">+{{ adds }}</span>
<span v-if="dels > 0" class="ch-del">-{{ dels }}</span>
</span>
</button>
</Tooltip>
{{ branch || t('header.detached') }}
</span>
<span v-if="ahead > 0 || behind > 0" class="ch-pill ch-sync-pill">
<span v-if="ahead > 0" class="ch-ahead">{{ ahead }}</span>
<span v-if="behind > 0" class="ch-behind">{{ behind }}</span>
</span>
<span v-if="hasLineStats" class="ch-pill ch-diff-pill">
<span v-if="adds > 0" class="ch-add">+{{ adds }}</span>
<span v-if="dels > 0" class="ch-del">-{{ dels }}</span>
</span>
</button>
<!-- GitHub PR status -->
<Tooltip :text="t('header.openPr')">
<button
v-if="pr"
type="button"
class="ch-pill ch-pr"
:class="`pr-${pr.state}`"
@click="pr && emit('openPr', pr.url)"
>
<Icon name="git-pull-request" size="sm" />
<span>PR #{{ pr.number }} · {{ pr.state }}</span>
</button>
</Tooltip>
<button
v-if="pr"
type="button"
class="ch-pill ch-pr"
:class="`pr-${pr.state}`"
@click="pr && emit('openPr', pr.url)"
>
<Icon name="git-pull-request" size="sm" />
<span>PR #{{ pr.number }} · {{ pr.state }}</span>
</button>
</header>
</template>

View file

@ -575,31 +575,25 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
</div>
<div v-if="turn.createdAt || canEditTurn(turn)" class="u-meta">
<div v-if="canEditTurn(turn)" class="u-edit-wrap" :class="{ undoing: undoingTurnId === turn.id }">
<Tooltip :text="t('conversation.undoTooltip')">
<button
type="button"
class="u-edit"
:aria-label="t('conversation.undoTooltip')"
@click="onUndo(turn)"
>
<Icon name="undo" size="sm" />
</button>
</Tooltip>
</div>
<Tooltip
v-if="turn.text.trim().length > 0"
:text="t('filePreview.copy')"
>
<button
type="button"
class="u-copy"
:aria-label="t('filePreview.copy')"
@click.stop="copyUserMessage(turn)"
class="u-edit"
:aria-label="t('conversation.undoTooltip')"
@click="onUndo(turn)"
>
<Icon v-if="copiedTurn !== turn.id" name="copy" size="sm" />
<Icon v-else name="check" size="sm" />
<Icon name="undo" size="sm" />
</button>
</Tooltip>
</div>
<button
v-if="turn.text.trim().length > 0"
type="button"
class="u-copy"
:aria-label="t('filePreview.copy')"
@click.stop="copyUserMessage(turn)"
>
<Icon v-if="copiedTurn !== turn.id" name="copy" size="sm" />
<Icon v-else name="check" size="sm" />
</button>
<button
v-if="turn.createdAt"
type="button"
@ -650,19 +644,15 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
<Tooltip :text="`${turn.durationMs} ms`">
<span v-if="turn.durationMs !== undefined" class="a-duration">{{ formatDuration(turn.durationMs) }}</span>
</Tooltip>
<Tooltip
<button
v-if="assistantRunFinalText(ti).trim().length > 0"
:text="t('filePreview.copy')"
class="a-cpbtn"
:aria-label="t('filePreview.copy')"
@click="copyAssistantRun(ti)"
>
<button
class="a-cpbtn"
:aria-label="t('filePreview.copy')"
@click="copyAssistantRun(ti)"
>
<Icon v-if="copiedTurn !== turn.id" name="copy" size="sm" />
<Icon v-else name="check" size="sm" />
</button>
</Tooltip>
<Icon v-if="copiedTurn !== turn.id" name="copy" size="sm" />
<Icon v-else name="check" size="sm" />
</button>
</div>
</div>
</template>
@ -733,16 +723,14 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
</div>
<span v-if="qi === 0" class="q-tag q-tag-next">{{ t('composer.queueNext') }}</span>
<span v-else class="q-tag q-tag-idx">#{{ qi + 1 }}</span>
<Tooltip :text="t('composer.remove')">
<button
type="button"
class="q-rm"
:aria-label="t('composer.remove')"
@click.stop="emit('unqueue', qi)"
>
<Icon name="close" size="sm" />
</button>
</Tooltip>
<button
type="button"
class="q-rm"
:aria-label="t('composer.remove')"
@click.stop="emit('unqueue', qi)"
>
<Icon name="close" size="sm" />
</button>
</div>
</div>
</div>

View file

@ -783,18 +783,16 @@ function selectModel(modelId: string): void {
@compositionend="handleCompositionEnd"
@input="handleInput"
/>
<Tooltip :text="expanded ? t('composer.collapseTitle') : t('composer.expandTitle')">
<button
v-if="expanded || isGrown"
class="expand-btn"
type="button"
:aria-label="expanded ? t('composer.collapseTitle') : t('composer.expandTitle')"
@click="toggleExpand"
>
<Icon v-if="expanded" name="collapse" size="sm" />
<Icon v-else name="expand" size="sm" />
</button>
</Tooltip>
<button
v-if="expanded || isGrown"
class="expand-btn"
type="button"
:aria-label="expanded ? t('composer.collapseTitle') : t('composer.expandTitle')"
@click="toggleExpand"
>
<Icon v-if="expanded" name="collapse" size="sm" />
<Icon v-else name="expand" size="sm" />
</button>
</div>
</div>
@ -813,30 +811,26 @@ function selectModel(modelId: string): void {
<div ref="toolbarRef" class="toolbar">
<!-- Left: attach + permission + plan -->
<div class="toolbar-left">
<Tooltip :text="t('composer.attachImage')">
<IconButton
v-if="hasUpload"
size="md"
:label="t('composer.attachImage')"
@click="openFilePicker"
>
<Icon name="image" />
</IconButton>
</Tooltip>
<IconButton
v-if="hasUpload"
size="md"
:label="t('composer.attachImage')"
@click="openFilePicker"
>
<Icon name="image" />
</IconButton>
<!-- Permission pill click to open dropdown -->
<Tooltip :text="t('status.permissionTooltip')">
<span
v-if="status"
class="perm-pill"
:class="['perm-' + status.permission, { open: permDropdownOpen }]"
role="button"
tabindex="0"
@click.stop="togglePermDropdown"
@keydown.enter="togglePermDropdown"
@keydown.space.prevent="togglePermDropdown"
>{{ permLabel }}</span>
</Tooltip>
<span
v-if="status"
class="perm-pill"
:class="['perm-' + status.permission, { open: permDropdownOpen }]"
role="button"
tabindex="0"
@click.stop="togglePermDropdown"
@keydown.enter="togglePermDropdown"
@keydown.space.prevent="togglePermDropdown"
>{{ permLabel }}</span>
<!-- Permission dropdown anchored to the toolbar left side -->
<div v-if="permDropdownOpen && status" class="perm-dropdown" role="menu" @click.stop>
@ -858,19 +852,17 @@ function selectModel(modelId: string): void {
<!-- Modes selector (plan / goal / swarm) replaces the plan pill. -->
<div v-if="status" ref="modesRef" class="modes">
<Tooltip :text="t('status.modesTooltip')">
<button
type="button"
class="mode-pill"
:class="{ on: anyModeActive, open: modesOpen }"
@click.stop="toggleModes"
>
<span class="mode-label">{{ t('status.modesLabel') }}</span>
<span v-if="planOn" class="mode-tag">{{ t('status.planLabel') }}</span>
<span v-if="swarmOn" class="mode-tag">{{ t('status.swarmLabel') }}</span>
<span v-if="goalArmed" class="mode-tag">{{ t('status.goalLabel') }}</span>
</button>
</Tooltip>
<button
type="button"
class="mode-pill"
:class="{ on: anyModeActive, open: modesOpen }"
@click.stop="toggleModes"
>
<span class="mode-label">{{ t('status.modesLabel') }}</span>
<span v-if="planOn" class="mode-tag">{{ t('status.planLabel') }}</span>
<span v-if="swarmOn" class="mode-tag">{{ t('status.swarmLabel') }}</span>
<span v-if="goalArmed" class="mode-tag">{{ t('status.goalLabel') }}</span>
</button>
<div v-if="modesOpen" ref="modesMenuRef" class="modes-menu" :style="modesMenuStyle">
<!-- Plan functional client toggle -->
@ -925,22 +917,20 @@ function selectModel(modelId: string): void {
</Tooltip>
<!-- Model pill click to open quick-switch dropdown -->
<Tooltip :text="t('status.modelTooltip')">
<span
v-if="status"
class="model-pill"
:class="{ open: dropdownOpen }"
role="button"
tabindex="0"
@click.stop="toggleDropdown"
@keydown.enter="toggleDropdown"
@keydown.space.prevent="toggleDropdown"
>
<b>{{ status.model }}</b>
<span v-if="thinkingOn" class="think-suffix">{{ t('composer.thinkingSuffix') }}</span>
<Icon class="cv" name="chevron-down" size="sm" />
</span>
</Tooltip>
<span
v-if="status"
class="model-pill"
:class="{ open: dropdownOpen }"
role="button"
tabindex="0"
@click.stop="toggleDropdown"
@keydown.enter="toggleDropdown"
@keydown.space.prevent="toggleDropdown"
>
<b>{{ status.model }}</b>
<span v-if="thinkingOn" class="think-suffix">{{ t('composer.thinkingSuffix') }}</span>
<Icon class="cv" name="chevron-down" size="sm" />
</span>
<Tooltip v-if="running" :text="t('composer.interruptTitle')">
<button
class="stop"
@ -950,15 +940,13 @@ function selectModel(modelId: string): void {
<Icon name="stop" size="sm" />
</button>
</Tooltip>
<Tooltip :text="sendLabel">
<button
class="send"
:aria-label="sendLabel"
@click="handleSubmit()"
>
<Icon name="send" size="sm" />
</button>
</Tooltip>
<button
class="send"
:aria-label="sendLabel"
@click="handleSubmit()"
>
<Icon name="send" size="sm" />
</button>
</div>
<!-- Model dropdown current provider models + controls + more -->

View file

@ -220,12 +220,10 @@ function treePadding(depth: number): string {
</PanelHeader>
<div class="diff-head">
<Tooltip :text="t('diff.back')">
<Button v-if="!hideBack" variant="ghost" size="sm" @click="onBack">
<span aria-hidden="true">&#8592;</span>
<span class="back-label">{{ t('diff.back') }}</span>
</Button>
</Tooltip>
<Button v-if="!hideBack" variant="ghost" size="sm" @click="onBack">
<span aria-hidden="true">&#8592;</span>
<span class="back-label">{{ t('diff.back') }}</span>
</Button>
</div>
<div v-if="loading" class="empty-state">{{ t('diff.loading') }}</div>

View file

@ -198,18 +198,16 @@ async function copyPath(): Promise<void> {
</Button>
</Tooltip>
<Tooltip :text="t('header.chooseOpenApp')">
<IconButton
ref="triggerRef"
size="sm"
:class="{ open: menuOpen }"
:disabled="!hasWorkDir"
:label="t('header.chooseOpenApp')"
@click.stop="openMenu"
>
<Icon name="chevron-down" size="sm" />
</IconButton>
</Tooltip>
<IconButton
ref="triggerRef"
size="sm"
:class="{ open: menuOpen }"
:disabled="!hasWorkDir"
:label="t('header.chooseOpenApp')"
@click.stop="openMenu"
>
<Icon name="chevron-down" size="sm" />
</IconButton>
<Menu
v-if="menuOpen"

View file

@ -10,7 +10,6 @@ import Badge from '../ui/Badge.vue';
import Button from '../ui/Button.vue';
import IconButton from '../ui/IconButton.vue';
import Icon from '../ui/Icon.vue';
import Tooltip from '../ui/Tooltip.vue';
const props = defineProps<{
question: UIQuestion;
@ -278,17 +277,15 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
<span v-if="total > 1 && !minimized" class="qstep">{{ t('question.step', { current: step + 1, total }) }}</span>
<!-- When minimized, surface the question text so the bar stays identifiable -->
<span v-if="minimized" class="qmin-peek">{{ current.question }}</span>
<Tooltip :text="minimized ? t('question.expand') : t('question.minimize')">
<IconButton
class="qmin"
size="sm"
:label="minimized ? t('question.expand') : t('question.minimize')"
@click="minimized = !minimized"
>
<Icon v-if="minimized" name="chevron-down" size="md" />
<Icon v-else name="minus" size="md" />
</IconButton>
</Tooltip>
<IconButton
class="qmin"
size="sm"
:label="minimized ? t('question.expand') : t('question.minimize')"
@click="minimized = !minimized"
>
<Icon v-if="minimized" name="chevron-down" size="md" />
<Icon v-else name="minus" size="md" />
</IconButton>
</div>
</template>

View file

@ -198,27 +198,23 @@ async function onDeleteWorkspace(ws: WorkspaceView): Promise<void> {
class="att"
>{{ wsAttention(g.workspace.id) }}</span>
<Tooltip :text="t('sidebar.options')">
<IconButton
size="lg"
class="mgh-more"
:label="t('sidebar.options')"
@click.stop="toggleWsMenu(g.workspace.id)"
>
<Icon name="dots-horizontal" size="md" />
</IconButton>
</Tooltip>
<IconButton
size="lg"
class="mgh-more"
:label="t('sidebar.options')"
@click.stop="toggleWsMenu(g.workspace.id)"
>
<Icon name="dots-horizontal" size="md" />
</IconButton>
<Tooltip :text="t('workspace.newInGroup')">
<IconButton
size="lg"
class="mgh-add"
:label="t('workspace.newInGroup')"
@click.stop="onCreateInWorkspace(g.workspace.id)"
>
<Icon name="plus" size="md" />
</IconButton>
</Tooltip>
<IconButton
size="lg"
class="mgh-add"
:label="t('workspace.newInGroup')"
@click.stop="onCreateInWorkspace(g.workspace.id)"
>
<Icon name="plus" size="md" />
</IconButton>
<!-- Workspace menu: copy path / delete (two-step confirm) -->
<Menu v-if="wsMenuFor === g.workspace.id" class="kmenu wsmenu" @click.stop>
@ -243,16 +239,14 @@ async function onDeleteWorkspace(ws: WorkspaceView): Promise<void> {
<div class="s">{{ s.time }}</div>
</div>
<span v-if="(attentionBySession[s.id] ?? 0) > 0" class="att">{{ attentionBySession[s.id] }}</span>
<Tooltip :text="t('sidebar.options')">
<IconButton
size="lg"
class="kb"
:label="t('sidebar.options')"
@click.stop="toggleMenu(s.id)"
>
<Icon name="dots-horizontal" size="md" />
</IconButton>
</Tooltip>
<IconButton
size="lg"
class="kb"
:label="t('sidebar.options')"
@click.stop="toggleMenu(s.id)"
>
<Icon name="dots-horizontal" size="md" />
</IconButton>
<!-- Kebab menu -->
<Menu v-if="menuFor === s.id" class="kmenu" @click.stop>

View file

@ -30,17 +30,15 @@ defineEmits<{ close: [] }>();
<span v-if="subtitle" class="ui-panel-header__sub">{{ subtitle }}</span>
</Tooltip>
<slot />
<Tooltip :text="closeLabel">
<IconButton
v-if="closable"
class="ui-panel-header__close"
size="sm"
:label="closeLabel"
@click="$emit('close')"
>
<Icon name="close" size="sm" />
</IconButton>
</Tooltip>
<IconButton
v-if="closable"
class="ui-panel-header__close"
size="sm"
:label="closeLabel"
@click="$emit('close')"
>
<Icon name="close" size="sm" />
</IconButton>
</div>
</template>

View file

@ -2,11 +2,12 @@
<!-- Design-system §03 Tooltip: hover/focus hint. Wrap the trigger in the default
slot; text via prop. The wrapper is `display: contents` so it never alters the
trigger's layout (safe for truncated/flex triggers); listeners are attached to
the real trigger element, which also anchors the bubble. The bubble is rendered
through a body teleport so it escapes ancestor overflow clipping, and positioned
with flip + viewport clamping. Short text stays on one line; long text wraps
within `maxWidth` and is clamped to `maxLines` lines with an ellipsis so the
bubble never grows too tall. -->
the real trigger element, which also anchors the bubble, and re-attached if that
element is removed or replaced (so an open tooltip can never strand on screen).
The bubble is rendered through a body teleport so it escapes ancestor overflow
clipping, and positioned with flip + viewport clamping. Short text stays on one
line; long text wraps within `maxWidth` and is clamped to `maxLines` lines with
an ellipsis so the bubble never grows too tall. -->
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
@ -39,6 +40,7 @@ const bubbleStyle = ref<Record<string, string>>({ maxWidth: `${props.maxWidth}px
let showTimer: ReturnType<typeof setTimeout> | undefined;
let target: HTMLElement | null = null;
let observer: MutationObserver | undefined;
function position(): void {
const bub = bubble.value;
@ -104,25 +106,47 @@ function onScrollOrResize(): void {
if (open.value) hide();
}
onMounted(() => {
target = (trigger.value?.firstElementChild as HTMLElement | null) ?? trigger.value ?? null;
if (!target) return;
target.addEventListener('mouseenter', show);
target.addEventListener('mouseleave', hide);
target.addEventListener('focusin', show);
target.addEventListener('focusout', hide);
window.addEventListener('scroll', onScrollOrResize, true);
window.addEventListener('resize', onScrollOrResize);
});
onBeforeUnmount(() => {
window.clearTimeout(showTimer);
function setTarget(el: HTMLElement | null): void {
if (el === target) return;
if (target) {
target.removeEventListener('mouseenter', show);
target.removeEventListener('mouseleave', hide);
target.removeEventListener('focusin', show);
target.removeEventListener('focusout', hide);
}
target = el;
if (target) {
target.addEventListener('mouseenter', show);
target.addEventListener('mouseleave', hide);
target.addEventListener('focusin', show);
target.addEventListener('focusout', hide);
}
}
onMounted(() => {
const root = trigger.value ?? null;
setTarget((root?.firstElementChild as HTMLElement | null) ?? root);
// Keep `target` in sync with the live slotted element: if it's removed or
// replaced while the tooltip is open (e.g. a v-if toggles on hover), the
// mouseleave we rely on never fires and the bubble would get stuck on screen.
if (root) {
observer = new MutationObserver(() => {
const next = (root.firstElementChild as HTMLElement | null) ?? null;
if (next !== target) {
hide();
setTarget(next ?? root);
}
});
observer.observe(root, { childList: true });
}
window.addEventListener('scroll', onScrollOrResize, true);
window.addEventListener('resize', onScrollOrResize);
});
onBeforeUnmount(() => {
window.clearTimeout(showTimer);
observer?.disconnect();
setTarget(null);
window.removeEventListener('scroll', onScrollOrResize, true);
window.removeEventListener('resize', onScrollOrResize);
});

View file

@ -4,11 +4,9 @@ export default {
connectionDisconnected: 'Disconnected',
ctxTooltip: 'Used {used} / {max} tokens ({pct}%)',
modelLabel: 'Model',
modelTooltip: 'Click to switch model',
permissionManual: 'Manual',
permissionAuto: 'Auto',
permissionYolo: 'YOLO',
permissionTooltip: 'Click to cycle permission mode',
permissionManualDesc: 'Ask for approval on every tool action',
permissionAutoDesc: 'Fully autonomous — agent decides everything without asking',
permissionYoloDesc: 'Auto-approve tool actions, but agent may still ask questions',
@ -19,7 +17,6 @@ export default {
planTooltip: 'Toggle plan mode (research before editing)',
// Mode selector (Plan / Goal / Swarm)
modesLabel: 'Mode',
modesTooltip: 'Mode: Plan / Goal / Swarm',
goalLabel: 'Goal',
swarmLabel: 'Swarm',
modeOff: 'Off',

View file

@ -4,11 +4,9 @@ export default {
connectionDisconnected: '未连接',
ctxTooltip: '使用 {used} / {max} tokens ({pct}%)',
modelLabel: '模型',
modelTooltip: '点击切换模型',
permissionManual: '逐条确认',
permissionAuto: '完全自主',
permissionYolo: '自动通过',
permissionTooltip: '点击循环切换审批模式',
permissionManualDesc: '每个工具操作都需要你手动确认',
permissionAutoDesc: '完全自主运行agent 自己做决定,不再询问',
permissionYoloDesc: '自动批准工具操作,但遇到关键问题仍会询问',
@ -19,7 +17,6 @@ export default {
planTooltip: '切换计划模式(先调研再修改)',
// 模式选择器(计划 / 目标 / Swarm
modesLabel: '模式',
modesTooltip: '模式:计划 / 目标 / Swarm',
goalLabel: '目标',
swarmLabel: 'Swarm',
modeOff: '未启用',