Compare commits

...

5 Commits

16 changed files with 535 additions and 9 deletions

View File

@ -4,6 +4,7 @@ ENV DEBIAN_FRONTEND=noninteractive \
LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8
# 1. 安装系统工具与运行时库
RUN apt-get update && \
apt-get install -y --no-install-recommends \
bash \
@ -18,15 +19,54 @@ RUN apt-get update && \
unzip \
locales \
tzdata \
iputils-ping && \
iputils-ping \
# Office 转换
libreoffice \
pandoc \
poppler-utils \
# 图片/视频/媒体
imagemagick \
ffmpeg \
# OCR
tesseract-ocr \
tesseract-ocr-chi-sim \
tesseract-ocr-chi-tra \
# 网页截图 / 自动化
chromium \
# 开发辅助工具
jq \
ripgrep \
fd-find \
tree \
# 字体
fonts-noto-cjk \
fonts-noto-color-emoji \
fonts-liberation && \
sed -i 's/# en_US.UTF-8/en_US.UTF-8/' /etc/locale.gen && \
locale-gen && \
rm -rf /var/lib/apt/lists/*
# 2. 允许 ImageMagick 处理 PDF默认被安全策略禁止
RUN if [ -f /etc/ImageMagick-6/policy.xml ]; then \
sed -i 's/<policy domain="coder" rights="none" pattern="PDF" \/>/<policy domain="coder" rights="read|write" pattern="PDF" \/>/g' /etc/ImageMagick-6/policy.xml; \
fi
# 3. 安装 Node.js 20 LTS复用 curl已在上方安装
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
apt-get install -y --no-install-recommends nodejs && \
rm -rf /var/lib/apt/lists/* && \
node --version && \
npm --version
# 3.5 全局安装 office skill 需要的 Node 包
RUN npm install -g docx pptxgenjs && \
npm cache clean --force
WORKDIR /opt/workspace
COPY docker/toolbox-requirements.txt /tmp/toolbox-requirements.txt
# 4. 创建 Python 虚拟环境并安装依赖
RUN python -m venv /opt/agent-venv && \
/opt/agent-venv/bin/pip install --no-cache-dir --upgrade pip && \
/opt/agent-venv/bin/pip install --no-cache-dir -r /tmp/toolbox-requirements.txt && \
@ -34,3 +74,5 @@ RUN python -m venv /opt/agent-venv && \
ENV AGENT_TOOLBOX_VENV=/opt/agent-venv
ENV PATH="/opt/agent-venv/bin:${PATH}"
ENV NODE_PATH="/usr/lib/node_modules"

View File

@ -16,3 +16,9 @@ openpyxl==3.1.5
python-pptx==1.0.2
pdfplumber==0.11.4
PyPDF2==3.0.1
# office skill 运行必需
defusedxml
markitdown[docx]
# OCR / PDF 转图片
pytesseract
pdf2image

View File

@ -464,6 +464,7 @@ async def _dispatch_completion_user_notice(
session_data["auto_user_message_payload"] = {
**dict(extra_payload or {}),
**ui_defaults,
"timestamp": datetime.now().isoformat(),
}
# 轮询客户端通过后续任务事件流回放前置通知(在线客户端已由上面的 socketio 回显覆盖)。
if preceding_notices:
@ -491,6 +492,7 @@ async def _dispatch_completion_user_notice(
'conversation_id': conversation_id,
'task_id': rec.task_id,
'message_source': message_source,
'timestamp': datetime.now().isoformat(),
**ui_defaults,
}
payload.update(extra_payload)
@ -508,6 +510,7 @@ async def _dispatch_completion_user_notice(
'message': user_message,
'conversation_id': conversation_id,
'message_source': message_source,
'timestamp': datetime.now().isoformat(),
}
payload.update(_user_message_ui_defaults(message_source, auto_user_message_event=True))
payload.update(extra_payload)
@ -976,6 +979,7 @@ async def handle_task_with_sender(
"starts_work": user_message_metadata.get("starts_work"),
"metadata": user_message_metadata,
"conversation_id": conversation_id,
"timestamp": (saved_user_message or {}).get("timestamp") or user_work_started_at,
},
)
except Exception as exc:

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><circle cx="12" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><path d="M18 9v2c0 .6-.4 1-1 1H7c-.6 0-1-.4-1-1V9m6 3v3"/></svg>

After

Width:  |  Height:  |  Size: 349 B

View File

@ -235,6 +235,7 @@
:format-search-topic="formatSearchTopic"
:format-search-time="formatSearchTime"
:format-search-domains="formatSearchDomains"
:history-loading="historyLoading"
@stick-state-change="handleStickStateChange"
@user-scroll-intent="handleUserScrollIntent"
/>

View File

@ -199,7 +199,7 @@ export const historyMethods = {
// 滚动到底部
this.$nextTick(() => {
this.scrollToBottom();
this.scrollHistoryToBottomInstant();
});
debugLog('历史对话内容显示完成');
@ -278,13 +278,19 @@ export const historyMethods = {
if (Array.isArray(videos) && videos.length) {
historyHasVideos = true;
}
const rawCreatedAt = message.created_at ?? message.createdAt ?? message.timestamp ?? null;
const normalizedCreatedAt =
typeof rawCreatedAt === 'number' && rawCreatedAt > 0
? new Date(rawCreatedAt * 1000).toISOString()
: rawCreatedAt || null;
this.messages.push({
role: 'user',
content: message.content || '',
images,
videos,
media_refs: Array.isArray(mediaRefs) ? mediaRefs : [],
metadata: message.metadata || {}
metadata: message.metadata || {},
created_at: normalizedCreatedAt
});
debugLog('添加用户消息:', message.content?.substring(0, 50) + '...');
} else if (message.role === 'assistant') {
@ -526,7 +532,7 @@ export const historyMethods = {
// 确保滚动到底部
this.$nextTick(() => {
this.scrollToBottom();
this.scrollHistoryToBottomInstant();
setTimeout(() => {
const blockCount =
this.$el && this.$el.querySelectorAll

View File

@ -75,6 +75,10 @@ export const messagingMethods = {
...eventMetadata,
media_refs: incomingMediaRefs
};
const backendTimestamp = data?.timestamp ?? data?.created_at ?? data?.createdAt ?? null;
if (backendTimestamp) {
target.created_at = backendTimestamp;
}
} else {
this.chatAddUserMessage(
message,
@ -122,6 +126,10 @@ export const messagingMethods = {
media_refs: incomingMediaRefs,
message_source: source
};
const backendTimestamp = data?.timestamp ?? data?.created_at ?? data?.createdAt ?? null;
if (backendTimestamp) {
recentMatch.created_at = backendTimestamp;
}
} else {
this.chatAddUserMessage(
message,

View File

@ -148,6 +148,26 @@ export const scrollMethods = {
80
);
},
scrollHistoryToBottomInstant() {
this._escapedByUserScroll = false;
const chatArea = this.getChatAreaController();
if (chatArea && typeof chatArea.stopStickScroll === 'function') {
chatArea.stopStickScroll();
}
const area = this.getMessagesAreaElement();
if (!area) {
return;
}
const jump = () => {
area.scrollTop = area.scrollHeight;
};
jump();
requestAnimationFrame(() => {
jump();
requestAnimationFrame(jump);
});
this.chatSetScrollState({ userScrolling: false });
},
scrollToBottom() {
uiBounceTrace(
'ui.scrollToBottom:called',

View File

@ -234,7 +234,6 @@ export const socketMethods = {
throw new Error(`状态接口请求失败: ${statusResponse.status}`);
}
const statusData = await statusResponse.json();
this.isConnected = true;
this.socket = null;
this.projectPath = statusData.project_path || '';
this.agentVersion = statusData.version || this.agentVersion;
@ -296,6 +295,7 @@ export const socketMethods = {
this.thinkingMode = false;
}
}
this.isConnected = true;
const focusPromise = this.focusFetchFiles();
let treePromise: Promise<any> | null = null;

View File

@ -20,10 +20,19 @@
<span class="icon icon-sm" :style="iconStyleSafe(userHeaderIconKey(msg))" aria-hidden="true"></span>
<span>{{ userHeaderLabel(msg) }}</span>
</div>
<div class="message-text user-bubble-text">
<div
class="message-text user-bubble-text"
:class="{
'is-collapsed': isUserBubbleCollapsed(msg, index),
'is-expandable': isUserBubbleExpandable(msg, index),
'is-history-loading': props.historyLoading || userBubbleTransitionSuppressed
}"
>
<div
v-if="msg.content"
class="bubble-text"
:class="{ 'is-expanded': isUserBubbleExpanded(msg, index) }"
:ref="(el) => registerUserBubbleRef(msg, index, el)"
v-html="renderUserMessageContent(msg.content)"
></div>
<div v-if="msg.images && msg.images.length" class="image-inline-row">
@ -52,6 +61,34 @@
/>
</div>
</div>
<div
v-if="isUserBubbleExpandable(msg, index)"
class="user-bubble-expand-row"
>
<button
class="user-bubble-action-btn user-bubble-expand-btn"
:class="{ expanded: isUserBubbleExpanded(msg, index) }"
:title="isUserBubbleExpanded(msg, index) ? '收起' : '展开'"
:aria-label="isUserBubbleExpanded(msg, index) ? '收起' : '展开'"
@click.stop="toggleUserBubble(msg, index)"
></button>
</div>
</div>
<div class="user-bubble-actions">
<button
class="user-bubble-action-btn copy"
:class="{ copied: isUserBubbleCopied(msg, index) }"
:title="isUserBubbleCopied(msg, index) ? '已复制' : '复制'"
:aria-label="isUserBubbleCopied(msg, index) ? '已复制' : '复制'"
@click="copyUserMessage(msg, index)"
></button>
<button
class="user-bubble-action-btn branch"
title="分支"
aria-label="分支"
@click="branchUserMessage(msg)"
></button>
<span class="user-bubble-time">{{ formatUserMessageTime(msg) }}</span>
</div>
</template>
</div>
@ -605,7 +642,7 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue';
import { useStickToBottom } from 'vue-stick-to-bottom';
import { useBlockExpansionAnchor } from '@/composables/useBlockExpansionAnchor';
import ToolAction from '@/components/chat/actions/ToolAction.vue';
@ -630,6 +667,7 @@ const props = defineProps<{
formatSearchTopic: (filters: Record<string, any>) => string;
formatSearchTime: (filters: Record<string, any>) => string;
formatSearchDomains: (filters: Record<string, any>) => string;
historyLoading?: boolean;
}>();
const emit = defineEmits<{
(
@ -666,6 +704,184 @@ const userName = computed(() => {
}
return personalization.form.user_name || '用户';
});
//
interface UserBubbleFoldState {
needsFold: boolean;
expanded: boolean;
foldHeight: number;
fullHeight: number;
}
const USER_BUBBLE_FOLD_LINES = 10;
const userBubbleRefs = new Map<string, HTMLElement>();
const userBubbleFoldStates = ref<Record<string, UserBubbleFoldState>>({});
const copiedBubbleKeys = ref<Set<string>>(new Set());
const userBubbleTransitionSuppressed = ref(false);
let userBubbleResizeObserver: ResizeObserver | null = null;
let copiedBubbleTimeouts = new Map<string, number>();
let userBubbleTransitionSeq = 0;
const getUserBubbleKey = (msg: any, index: number) => msg?.id || `user-bubble-${index}`;
const registerUserBubbleRef = (msg: any, index: number, el: Element | null) => {
const key = getUserBubbleKey(msg, index);
if (el instanceof HTMLElement) {
userBubbleRefs.set(key, el);
} else {
userBubbleRefs.delete(key);
}
};
const estimateUserBubbleNeedsFold = (msg: any): boolean => {
const content = typeof msg?.content === 'string' ? msg.content : '';
if (!content) return false;
// 48
//
const APPROX_CHARS_PER_LINE = 48;
const foldChars = APPROX_CHARS_PER_LINE * (USER_BUBBLE_FOLD_LINES + 2);
const newlineCount = (content.match(/\n/g) || []).length;
return content.length > foldChars || newlineCount >= USER_BUBBLE_FOLD_LINES + 2;
};
const preMeasureUserBubbles = () => {
// DOM
//
//
filteredMessages.value.forEach((msg, index) => {
if (!msg || msg.role !== 'user') return;
const key = getUserBubbleKey(msg, index);
if (userBubbleFoldStates.value[key]) return;
const needsFold = estimateUserBubbleNeedsFold(msg);
userBubbleFoldStates.value[key] = {
needsFold,
expanded: false,
foldHeight: 0,
fullHeight: 0
};
});
};
const measureUserBubbles = () => {
userBubbleRefs.forEach((el, key) => {
if (!el.isConnected) {
userBubbleRefs.delete(key);
return;
}
const computed = window.getComputedStyle(el);
const rawLineHeight = parseFloat(computed.lineHeight);
const lineHeight = Number.isFinite(rawLineHeight) ? rawLineHeight : 24;
const foldHeight = Math.round(lineHeight * USER_BUBBLE_FOLD_LINES);
const fullHeight = Math.ceil(el.scrollHeight);
const needsFold = fullHeight > foldHeight + 1;
const existing = userBubbleFoldStates.value[key];
userBubbleFoldStates.value[key] = {
needsFold,
expanded: existing?.expanded ?? false,
foldHeight,
fullHeight
};
el.style.setProperty('--bubble-fold-height', `${foldHeight}px`);
el.style.setProperty('--bubble-full-height', `${fullHeight}px`);
});
};
const isUserBubbleExpandable = (msg: any, index: number) => {
const key = getUserBubbleKey(msg, index);
return !!userBubbleFoldStates.value[key]?.needsFold;
};
const isUserBubbleExpanded = (msg: any, index: number) => {
const key = getUserBubbleKey(msg, index);
return !!userBubbleFoldStates.value[key]?.expanded;
};
const isUserBubbleCollapsed = (msg: any, index: number) => {
const key = getUserBubbleKey(msg, index);
const state = userBubbleFoldStates.value[key];
return !!state?.needsFold && !state?.expanded;
};
const toggleUserBubble = (msg: any, index: number) => {
const key = getUserBubbleKey(msg, index);
const state = userBubbleFoldStates.value[key];
if (!state || !state.needsFold) return;
userBubbleFoldStates.value[key] = { ...state, expanded: !state.expanded };
};
const formatUserMessageTime = (msg: any): string => {
const createdAt = msg?.created_at;
if (!createdAt) return '';
const date = new Date(createdAt);
const now = new Date();
const diffMs = Math.max(0, now.getTime() - date.getTime());
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffDay >= 1) {
const month = date.getMonth() + 1;
const day = date.getDate();
const hour = String(date.getHours()).padStart(2, '0');
const minute = String(date.getMinutes()).padStart(2, '0');
return `${month}${day}${hour}:${minute}`;
}
if (diffHour > 0) return `${diffHour}小时前`;
if (diffMin > 0) return `${diffMin}分钟前`;
if (diffSec > 0) return `${diffSec}秒前`;
return '刚刚';
};
const isUserBubbleCopied = (msg: any, index: number) => {
const key = getUserBubbleKey(msg, index);
return copiedBubbleKeys.value.has(key);
};
const copyUserMessage = async (msg: any, index: number) => {
const key = getUserBubbleKey(msg, index);
const text = typeof msg?.content === 'string' ? msg.content : '';
if (!text || copiedBubbleKeys.value.has(key)) return;
try {
await navigator.clipboard.writeText(text);
copiedBubbleKeys.value.add(key);
const existing = copiedBubbleTimeouts.get(key);
if (existing) window.clearTimeout(existing);
const timeout = window.setTimeout(() => {
copiedBubbleKeys.value.delete(key);
copiedBubbleTimeouts.delete(key);
}, 5000);
copiedBubbleTimeouts.set(key, timeout);
} catch (err) {
console.warn('复制用户消息失败:', err);
}
};
const branchUserMessage = (_msg: any) => {
// TODO:
void _msg;
console.log('[ChatArea] 分支功能待实现');
};
const observeUserBubbles = () => {
if (!userBubbleResizeObserver) return;
userBubbleResizeObserver.disconnect();
userBubbleRefs.forEach((el) => {
if (el.isConnected) userBubbleResizeObserver!.observe(el);
});
};
const releaseUserBubbleTransitionAfterLayout = () => {
const seq = ++userBubbleTransitionSeq;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (seq === userBubbleTransitionSeq) {
userBubbleTransitionSuppressed.value = false;
}
});
});
};
const isSystemAutoUserMessage = (message: any) => {
if (!message || message.role !== 'user') {
return false;
@ -1603,6 +1819,23 @@ function compactBriefLabel(msg: any): string {
}
}
watch(
() => filteredMessages.value.map((m) => m?.id ?? m?.content?.slice(0, 80)),
() => {
userBubbleTransitionSuppressed.value = true;
//
preMeasureUserBubbles();
nextTick(() => {
requestAnimationFrame(() => {
measureUserBubbles();
observeUserBubbles();
releaseUserBubbleTransitionAfterLayout();
});
});
},
{ immediate: true }
);
onMounted(() => {
attachBounceListener();
updateChatScrollbarWidth();
@ -1610,6 +1843,12 @@ onMounted(() => {
scrollbarResizeObserver = new ResizeObserver(updateChatScrollbarWidth);
scrollbarResizeObserver.observe(scrollRef.value);
}
if (typeof ResizeObserver !== 'undefined') {
userBubbleResizeObserver = new ResizeObserver(() => {
requestAnimationFrame(measureUserBubbles);
});
nextTick(() => observeUserBubbles());
}
console.warn('[SCROLL_BOUNCE_TRACE]', 'mounted', {
hasScrollRef: !!scrollRef.value
});
@ -1638,6 +1877,13 @@ onBeforeUnmount(() => {
scrollbarResizeObserver.disconnect();
scrollbarResizeObserver = null;
}
if (userBubbleResizeObserver) {
userBubbleResizeObserver.disconnect();
userBubbleResizeObserver = null;
}
userBubbleRefs.clear();
copiedBubbleTimeouts.forEach((t) => window.clearTimeout(t));
copiedBubbleTimeouts.clear();
if (timerHandle !== null) {
clearInterval(timerHandle);
timerHandle = null;

View File

@ -966,6 +966,10 @@ export async function initializeLegacySocket(ctx: any) {
...eventMetadata,
media_refs: incomingMediaRefs
};
const backendTimestamp = data?.timestamp ?? data?.created_at ?? data?.createdAt ?? null;
if (backendTimestamp) {
last.created_at = backendTimestamp;
}
} else {
ctx.chatAddUserMessage(message, incomingImages, incomingVideos, incomingMediaRefs, source, eventMetadata);
}

View File

@ -210,7 +210,8 @@ export const useChatStore = defineStore('chat', {
images,
videos,
media_refs: Array.isArray(mediaRefs) ? mediaRefs : [],
metadata
metadata,
created_at: startedAt
});
this.currentMessageIndex = -1;
},

View File

@ -46,6 +46,22 @@ export const useModelStore = defineStore('model', {
const exists = this.models.some((m) => m.key === key);
if (exists) {
this.currentModelKey = key;
return;
}
// 如果列表中还没有该模型(例如 fetchModels 失败),
// 临时插入一个 fallback 项,避免首屏显示“未选择模型”
if (key) {
this.models = [
...this.models,
{
key,
label: key,
description: '',
fastOnly: false,
supportsThinking: true
}
];
this.currentModelKey = key;
}
},
async fetchModels() {

View File

@ -98,6 +98,7 @@ const DEFAULT_DEEP_COMPRESS_TRIGGER_TOKENS = 150000;
const RUN_MODE_OPTIONS: RunMode[] = ['fast', 'thinking', 'deep'];
const EXPERIMENT_STORAGE_KEY = 'agents_personalization_experiments';
const THEME_STORAGE_KEY = 'agents_ui_theme';
const STACKED_HIDE_BORDERS_STORAGE_KEY = 'agents_stacked_hide_borders';
const loadCachedTheme = (): PersonalForm['theme'] => {
if (typeof window === 'undefined' || !window.localStorage) {
@ -107,6 +108,24 @@ const loadCachedTheme = (): PersonalForm['theme'] => {
return saved === 'light' || saved === 'dark' || saved === 'classic' ? saved : 'classic';
};
const loadCachedStackedHideBorders = (): boolean => {
if (typeof window === 'undefined' || !window.localStorage) {
return false;
}
return window.localStorage.getItem(STACKED_HIDE_BORDERS_STORAGE_KEY) === 'true';
};
const persistStackedHideBorders = (value: boolean) => {
if (typeof window === 'undefined' || !window.localStorage) {
return;
}
try {
window.localStorage.setItem(STACKED_HIDE_BORDERS_STORAGE_KEY, value ? 'true' : 'false');
} catch (error) {
console.warn('写入堆叠块边线设置失败:', error);
}
};
const defaultForm = (): PersonalForm => ({
enabled: false,
communication_style: 'default',
@ -125,7 +144,7 @@ const defaultForm = (): PersonalForm => ({
compact_message_display: 'full',
show_git_status_bar: true,
auto_open_terminal_panel: true,
stacked_hide_borders: false,
stacked_hide_borders: loadCachedStackedHideBorders(),
enhanced_tool_display_categories: [],
enabled_skills: [],
self_identify: '',
@ -398,6 +417,7 @@ export const usePersonalizationStore = defineStore('personalization', {
this.applyTheme(this.form.theme);
}
persistDefaultHideWorkspace(this.form.default_hide_workspace);
persistStackedHideBorders(this.form.stacked_hide_borders);
if (this.form.default_hide_workspace) {
useUiStore().setWorkspaceCollapsed(true);
}
@ -593,6 +613,9 @@ export const usePersonalizationStore = defineStore('personalization', {
...this.form,
[payload.key]: payload.value
};
if (payload.key === 'stacked_hide_borders') {
persistStackedHideBorders(!!payload.value);
}
this.clearFeedback();
this.scheduleAutoSave();
},

View File

@ -13,6 +13,13 @@ body {
-webkit-font-smoothing: antialiased;
}
button,
input,
textarea,
select {
font-family: "SF Pro Text", "Helvetica Neue", ui-sans-serif, system-ui, -apple-system, sans-serif;
}
body[data-theme='dark'] {
background: var(--claude-bg);
color: var(--claude-text);

View File

@ -576,6 +576,147 @@
margin: 0 0.25em;
}
/* 用户输入气泡:超长折叠、展开动画与悬停操作按钮 */
.user-message .message-text.user-bubble-text .bubble-text {
overflow: hidden;
transition: max-height 300ms cubic-bezier(0.4, 0, 0.2, 1);
max-height: var(--bubble-full-height, 9999px);
white-space: pre-wrap;
word-break: break-all;
}
.user-message .message-text.user-bubble-text.is-history-loading .bubble-text {
transition: none !important;
}
.user-message .message-text.user-bubble-text.is-expandable .bubble-text:not(.is-expanded) {
max-height: var(--bubble-fold-height, 240px);
}
/* 展开/收起按钮独占一行,不遮挡气泡内容,始终显示 */
.user-bubble-expand-row {
display: flex;
align-items: center;
justify-content: flex-end;
margin-top: -4px;
margin-bottom: -10px;
opacity: 1;
transform: none;
pointer-events: auto;
align-self: flex-end;
width: 100%;
min-height: 22px;
}
.user-bubble-expand-row .user-bubble-expand-btn {
width: 22px;
height: 22px;
margin: 0;
padding: 0;
opacity: 1;
transform: none;
pointer-events: auto;
background: transparent;
color: var(--claude-text-tertiary);
border-radius: 4px;
transition: color 140ms ease;
}
.user-bubble-expand-row .user-bubble-expand-btn:hover {
background: transparent;
color: var(--claude-text);
}
.user-bubble-actions {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 4px;
margin-top: 6px;
opacity: 0;
transform: translateY(4px);
pointer-events: none;
transition:
opacity 180ms ease,
transform 180ms ease;
white-space: normal;
align-self: flex-end;
}
.user-bubble-time {
font-size: 12px;
color: var(--claude-text-secondary);
margin-left: auto;
padding-left: 8px;
line-height: 20px;
user-select: none;
}
.user-message:not(.user-message--compact):not(.user-message--brief):hover .user-bubble-actions,
.user-message:not(.user-message--compact):not(.user-message--brief):focus-within .user-bubble-actions {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.user-bubble-action-btn {
width: 20px;
height: 20px;
border: none;
border-radius: 6px;
padding: 0;
background: transparent;
color: var(--claude-text-secondary);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition:
background-color 140ms ease,
color 140ms ease;
flex-shrink: 0;
}
.user-bubble-action-btn:hover {
background-color: var(--theme-tab-active);
color: var(--claude-text);
}
.user-bubble-action-btn::before {
content: '';
width: 14px;
height: 14px;
display: block;
background: currentColor;
mask: center / contain no-repeat;
-webkit-mask: center / contain no-repeat;
}
.user-bubble-action-btn.copy::before {
mask-image: url('/static/icons/copy.svg');
-webkit-mask-image: url('/static/icons/copy.svg');
}
.user-bubble-action-btn.copy.copied::before {
mask-image: url('/static/icons/check.svg');
-webkit-mask-image: url('/static/icons/check.svg');
}
.user-bubble-action-btn.branch::before {
mask-image: url('/static/icons/git-fork.svg');
-webkit-mask-image: url('/static/icons/git-fork.svg');
}
.user-bubble-action-btn.user-bubble-expand-btn::before {
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
transition: transform 260ms cubic-bezier(0.4, 0, 0.2, 1);
}
.user-bubble-action-btn.user-bubble-expand-btn.expanded::before {
transform: rotate(180deg);
}
.assistant-message .message-text {
background: var(--claude-highlight);
border-left: 4px solid var(--claude-accent);