The nav still used the ink palette: flat fills, oversized rows, and a tinted background with no marker for the active route. Adopt the CYBER treatment from the prototype — a 2px glowing cyan bar on the left of the active item, hairline borders instead of full-contrast ones, monospace group labels with wide tracking, and a faint cyan wash at the top of the rail so it does not read as a dead dark slab. PageSidebarNav gets the same active treatment as AppSidebar so the conversation routes do not feel like a different product. Header height stays at 64px — style-system.test.ts asserts it as a layout contract.
2757 lines
80 KiB
Vue
2757 lines
80 KiB
Vue
<script setup lang="ts">
|
|
import { renameSession, setSessionWorkspace, batchDeleteSessions, exportSession } from "@/api/hermes/sessions";
|
|
import { useChatStore, type Session } from "@/stores/hermes/chat";
|
|
import { useAppStore } from "@/stores/hermes/app";
|
|
import { useProfilesStore } from "@/stores/hermes/profiles";
|
|
import { useSessionBrowserPrefsStore } from "@/stores/hermes/session-browser-prefs";
|
|
import { useMessage } from "@/composables/useMessage";
|
|
import type { InputInstance } from "@arco-design/web-vue";
|
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
|
import { useRouter } from "vue-router";
|
|
import { useI18n } from "vue-i18n";
|
|
import { copyToClipboard } from "@/utils/clipboard";
|
|
import FolderPicker from "./FolderPicker.vue";
|
|
import ChatInput from "./ChatInput.vue";
|
|
import ConversationMonitorPane from "./ConversationMonitorPane.vue";
|
|
import MessageList from "./MessageList.vue";
|
|
import SessionListItem from "./SessionListItem.vue";
|
|
import OutlinePanel from "./OutlinePanel.vue";
|
|
import FilesPanel from "./FilesPanel.vue";
|
|
import TerminalPanel from "./TerminalPanel.vue";
|
|
import PageSidebarNav from "@/components/layout/PageSidebarNav.vue";
|
|
import { isStoredSuperAdmin } from "@/api/client";
|
|
import { useDefaultWorkspace } from "@/composables/useDefaultWorkspace";
|
|
|
|
const chatStore = useChatStore();
|
|
const appStore = useAppStore();
|
|
const profilesStore = useProfilesStore();
|
|
const sessionBrowserPrefsStore = useSessionBrowserPrefsStore();
|
|
const router = useRouter();
|
|
const message = useMessage();
|
|
const { t } = useI18n();
|
|
const isSuperAdmin = computed(() => isStoredSuperAdmin());
|
|
|
|
const showOutline = ref(false);
|
|
const messageListRef = ref<InstanceType<typeof MessageList> | null>(null);
|
|
const chatInputRef = ref<(InstanceType<typeof ChatInput> & { addFiles?: (files: File[]) => void }) | null>(null);
|
|
const chatContentWrapperRef = ref<HTMLElement | null>(null);
|
|
const chatDropCounter = ref(0);
|
|
const isChatDropActive = ref(false);
|
|
const showToolPanel = ref(false);
|
|
const activeToolPanel = ref<"files" | "terminal">("files");
|
|
const activeWorkspaceSessionId = computed(() => chatStore.activeSession?.workspace && !chatStore.activeSession.isLocalOnly ? chatStore.activeSession.id : null);
|
|
const activeWorkspacePath = computed(() => chatStore.activeSession?.workspace && !chatStore.activeSession.isLocalOnly ? chatStore.activeSession.workspace : null);
|
|
const TOOL_PANEL_MIN_WIDTH = 360;
|
|
const TOOL_PANEL_DEFAULT_WIDTH = 560;
|
|
// 工具栏要单行放下:附件 + 推理强度 + 设置 + 模型下拉(最长 190px)+ 语音 + 发送,
|
|
// 再加输入框左右内边距。窄于此值 Arco 按钮会被挤成竖排。
|
|
const CHAT_MAIN_MIN_WIDTH = 620;
|
|
const TOOL_PANEL_STORAGE_KEY = "hermes.chat.toolPanelWidth";
|
|
const toolPanelWidth = ref(loadToolPanelWidth());
|
|
const toolResizeStart = ref<{ x: number; width: number } | null>(null);
|
|
|
|
const currentMode = ref<"chat" | "live">("chat");
|
|
|
|
// Batch selection mode
|
|
const isBatchMode = ref(false);
|
|
const selectedSessionKeys = ref<Set<string>>(new Set());
|
|
const showBatchDeleteConfirm = ref(false);
|
|
const isBatchDeleting = ref(false);
|
|
|
|
// Initialize synchronously from the media query so first paint is correct.
|
|
// On narrow viewports the session list is an absolute-positioned overlay
|
|
// (z-index 10) on top of the chat area; if we default to `true`, onMounted
|
|
// only flips it to `false` AFTER the first render, causing a visible flash
|
|
// where the session list covers the chat content ("auto-fixes after a
|
|
// moment" — that was the race).
|
|
const showSessions = ref(
|
|
typeof window === "undefined" ||
|
|
!window.matchMedia("(max-width: 768px)").matches,
|
|
);
|
|
let mobileQuery: MediaQueryList | null = null;
|
|
const isMobile = ref(false);
|
|
const toolPanelStyle = computed(() => ({
|
|
width: isMobile.value ? "100%" : `${toolPanelWidth.value}px`,
|
|
}));
|
|
|
|
function sessionHref(sessionId: string) {
|
|
return router.resolve({
|
|
name: chatStore.runtimeMode === "global_agent" ? "hermes.globalAgentSession" : "hermes.session",
|
|
params: { sessionId },
|
|
}).href;
|
|
}
|
|
|
|
function openSessionInNewTab(sessionId: string) {
|
|
if (typeof window === "undefined") return;
|
|
window.open(sessionHref(sessionId), "_blank", "noopener,noreferrer");
|
|
}
|
|
|
|
function handleOutlineNavigate(target: { messageId: string; anchorId: string }) {
|
|
messageListRef.value?.scrollToAnchor(target.messageId, target.anchorId);
|
|
if (isMobile.value) showOutline.value = false;
|
|
}
|
|
|
|
function loadToolPanelWidth() {
|
|
if (typeof window === "undefined") return TOOL_PANEL_DEFAULT_WIDTH;
|
|
const saved = Number.parseInt(
|
|
window.localStorage.getItem(TOOL_PANEL_STORAGE_KEY) || "",
|
|
10,
|
|
);
|
|
return Number.isFinite(saved) ? Math.round(saved) : TOOL_PANEL_DEFAULT_WIDTH;
|
|
}
|
|
|
|
function toolPanelMaxWidth() {
|
|
if (typeof window === "undefined") return 1180;
|
|
if (isMobile.value) return window.innerWidth;
|
|
const available = chatContentWrapperRef.value?.clientWidth || window.innerWidth;
|
|
// 主区要放得下输入工具栏(附件 + 推理强度 + 设置 + 模型 + 语音 + 发送),
|
|
// 否则面板一展开工具栏就被压成竖排。
|
|
return Math.max(320, Math.min(Math.floor(available * 0.88), available - CHAT_MAIN_MIN_WIDTH));
|
|
}
|
|
|
|
function clampToolPanelWidth(width: number) {
|
|
const maxWidth = toolPanelMaxWidth();
|
|
const minWidth = Math.min(TOOL_PANEL_MIN_WIDTH, maxWidth);
|
|
return Math.min(maxWidth, Math.max(minWidth, Math.round(width)));
|
|
}
|
|
|
|
function handleToolPanelViewportResize() {
|
|
if (isMobile.value) return;
|
|
toolPanelWidth.value = clampToolPanelWidth(toolPanelWidth.value);
|
|
}
|
|
|
|
function handleToolResizeMove(event: PointerEvent) {
|
|
const start = toolResizeStart.value;
|
|
if (!start) return;
|
|
const delta = start.x - event.clientX;
|
|
toolPanelWidth.value = clampToolPanelWidth(start.width + delta);
|
|
}
|
|
|
|
function stopToolResize() {
|
|
if (!toolResizeStart.value) return;
|
|
toolResizeStart.value = null;
|
|
window.removeEventListener("pointermove", handleToolResizeMove);
|
|
window.removeEventListener("pointerup", stopToolResize);
|
|
if (!isMobile.value) {
|
|
window.localStorage.setItem(TOOL_PANEL_STORAGE_KEY, String(toolPanelWidth.value));
|
|
}
|
|
document.body.style.userSelect = "";
|
|
document.body.style.cursor = "";
|
|
}
|
|
|
|
function startToolResize(event: PointerEvent) {
|
|
if (isMobile.value) return;
|
|
event.preventDefault();
|
|
toolResizeStart.value = {
|
|
x: event.clientX,
|
|
width: toolPanelWidth.value,
|
|
};
|
|
window.addEventListener("pointermove", handleToolResizeMove);
|
|
window.addEventListener("pointerup", stopToolResize);
|
|
document.body.style.userSelect = "none";
|
|
document.body.style.cursor = "col-resize";
|
|
}
|
|
|
|
function hasDraggedFiles(event: DragEvent) {
|
|
return Array.from(event.dataTransfer?.types || []).includes("Files");
|
|
}
|
|
|
|
function resetChatDropState() {
|
|
chatDropCounter.value = 0;
|
|
isChatDropActive.value = false;
|
|
}
|
|
|
|
function handleChatDragOver(event: DragEvent) {
|
|
if (!hasDraggedFiles(event)) return;
|
|
event.preventDefault();
|
|
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
|
|
}
|
|
|
|
function handleChatDragEnter(event: DragEvent) {
|
|
if (!hasDraggedFiles(event)) return;
|
|
event.preventDefault();
|
|
chatDropCounter.value += 1;
|
|
isChatDropActive.value = true;
|
|
}
|
|
|
|
function handleChatDragLeave(event: DragEvent) {
|
|
if (!hasDraggedFiles(event)) return;
|
|
chatDropCounter.value -= 1;
|
|
if (chatDropCounter.value <= 0) resetChatDropState();
|
|
}
|
|
|
|
function handleChatDrop(event: DragEvent) {
|
|
if (!hasDraggedFiles(event)) return;
|
|
event.preventDefault();
|
|
const files = Array.from(event.dataTransfer?.files || []);
|
|
const target = event.target instanceof Element ? event.target : null;
|
|
resetChatDropState();
|
|
if (!files.length || target?.closest(".chat-input-area")) return;
|
|
chatInputRef.value?.addFiles?.(files);
|
|
}
|
|
|
|
async function handleSessionClick(sessionId: string) {
|
|
chatStore.clearSessionCompletedUnread(sessionId);
|
|
await router.push({
|
|
name: chatStore.runtimeMode === "global_agent" ? "hermes.globalAgentSession" : "hermes.session",
|
|
params: { sessionId },
|
|
});
|
|
if (chatStore.activeSessionId !== sessionId) {
|
|
await chatStore.switchSession(sessionId);
|
|
}
|
|
if (mobileQuery?.matches) showSessions.value = false;
|
|
}
|
|
|
|
function handleMobileChange(e: MediaQueryListEvent | MediaQueryList) {
|
|
isMobile.value = e.matches;
|
|
if (e.matches && showSessions.value) {
|
|
showSessions.value = false;
|
|
}
|
|
}
|
|
|
|
function openPageSidebar() {
|
|
showSessions.value = true;
|
|
}
|
|
|
|
onMounted(() => {
|
|
mobileQuery = window.matchMedia("(max-width: 768px)");
|
|
handleMobileChange(mobileQuery);
|
|
mobileQuery.addEventListener("change", handleMobileChange);
|
|
window.addEventListener("hermes:open-page-sidebar", openPageSidebar);
|
|
window.addEventListener("resize", handleToolPanelViewportResize);
|
|
handleToolPanelViewportResize();
|
|
if (profilesStore.profiles.length === 0) {
|
|
void profilesStore.fetchProfiles();
|
|
}
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
mobileQuery?.removeEventListener("change", handleMobileChange);
|
|
window.removeEventListener("hermes:open-page-sidebar", openPageSidebar);
|
|
window.removeEventListener("resize", handleToolPanelViewportResize);
|
|
stopToolResize();
|
|
});
|
|
watch(showToolPanel, async (visible) => {
|
|
if (!visible || isMobile.value) return;
|
|
await nextTick();
|
|
handleToolPanelViewportResize();
|
|
});
|
|
|
|
const showRenameModal = ref(false);
|
|
const renameValue = ref("");
|
|
const renameSessionId = ref<string | null>(null);
|
|
const renameInputRef = ref<InputInstance | null>(null);
|
|
const sessionProfileFilter = computed(() => chatStore.sessionProfileFilter);
|
|
const profileFilterOptions = computed(() => [
|
|
{ label: t("chat.allProfiles"), value: "__all__" },
|
|
...profilesStore.profiles.map((profile) => ({
|
|
label: profile.name,
|
|
value: profile.name,
|
|
})),
|
|
]);
|
|
|
|
async function handleProfileFilterChange(selected: unknown) {
|
|
const value = String(selected ?? "__all__");
|
|
chatStore.sessionProfileFilter = value === "__all__" ? null : value;
|
|
await chatStore.loadSessions(chatStore.sessionProfileFilter);
|
|
}
|
|
|
|
function sortSessionsForSidebar(items: Session[]): Session[] {
|
|
return [...items].sort((a, b) => {
|
|
const aLive = chatStore.isSessionLive(a.id);
|
|
const bLive = chatStore.isSessionLive(b.id);
|
|
if (aLive !== bLive) return aLive ? -1 : 1;
|
|
return (b.updatedAt || 0) - (a.updatedAt || 0);
|
|
});
|
|
}
|
|
|
|
const pinnedSessions = computed(() =>
|
|
sortSessionsForSidebar(
|
|
chatStore.sessions.filter((session) =>
|
|
sessionBrowserPrefsStore.isPinned(session.id),
|
|
),
|
|
),
|
|
);
|
|
|
|
const unpinnedSessions = computed(() =>
|
|
sortSessionsForSidebar(
|
|
chatStore.sessions.filter(
|
|
(session) => !sessionBrowserPrefsStore.isPinned(session.id),
|
|
),
|
|
),
|
|
);
|
|
|
|
watch(
|
|
() => [
|
|
chatStore.sessionsLoaded,
|
|
...chatStore.sessions.map((session) => session.id),
|
|
],
|
|
(value) => {
|
|
const sessionIds = value.slice(1) as string[];
|
|
if (!value[0] || sessionIds.length === 0) return;
|
|
sessionBrowserPrefsStore.pruneMissingSessions(sessionIds);
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
const activeSessionTitle = computed(
|
|
() => chatStore.activeSession?.title || t("chat.newChat"),
|
|
);
|
|
|
|
const activeSessionModelLabel = computed(() => {
|
|
const session = chatStore.activeSession;
|
|
if (!session?.model) return t("models.selectModel");
|
|
return appStore.displayModelName(session.model, session.provider);
|
|
});
|
|
|
|
const headerTitle = computed(() =>
|
|
currentMode.value === "live"
|
|
? t("chat.liveSessions")
|
|
: activeSessionTitle.value,
|
|
);
|
|
|
|
const showNewChatModal = ref(false);
|
|
const newChatProfile = ref<string>("default");
|
|
const newChatProvider = ref<string>("");
|
|
const newChatModel = ref<string>("");
|
|
const newChatWorkspace = ref("");
|
|
const newChatLoading = ref(false);
|
|
|
|
// Default workspace feature (multiple defaults supported)
|
|
const defaultWorkspaces = ref<string[]>([]);
|
|
const recentWorkspaces = ref<Array<{ path: string; lastUsed: number; useCount: number }>>([]);
|
|
let workspaceComposable: ReturnType<typeof useDefaultWorkspace> | null = null;
|
|
|
|
function initWorkspaceComposable(profile: string) {
|
|
workspaceComposable = useDefaultWorkspace(profile);
|
|
defaultWorkspaces.value = workspaceComposable.loadDefaultWorkspaces();
|
|
recentWorkspaces.value = workspaceComposable.loadRecentWorkspaces();
|
|
}
|
|
|
|
function handleToggleDefaultWorkspace() {
|
|
if (!workspaceComposable) return;
|
|
const currentPath = newChatWorkspace.value;
|
|
if (!currentPath) return;
|
|
|
|
const isDefault = defaultWorkspaces.value.includes(currentPath);
|
|
if (isDefault) {
|
|
workspaceComposable.removeDefaultWorkspace(currentPath);
|
|
defaultWorkspaces.value = defaultWorkspaces.value.filter(p => p !== currentPath);
|
|
} else {
|
|
workspaceComposable.addDefaultWorkspace(currentPath);
|
|
defaultWorkspaces.value = [...defaultWorkspaces.value, currentPath];
|
|
}
|
|
}
|
|
|
|
function handleSelectRecentWorkspace(path: string) {
|
|
newChatWorkspace.value = path;
|
|
}
|
|
|
|
function handleSelectDefaultWorkspace(path: string) {
|
|
newChatWorkspace.value = path;
|
|
showDefaultWorkspaceMenu.value = false;
|
|
}
|
|
|
|
function handleTogglePinRecent(path: string) {
|
|
if (!workspaceComposable) return;
|
|
const isDefault = defaultWorkspaces.value.includes(path);
|
|
if (isDefault) {
|
|
workspaceComposable.removeDefaultWorkspace(path);
|
|
defaultWorkspaces.value = defaultWorkspaces.value.filter(p => p !== path);
|
|
} else {
|
|
workspaceComposable.addDefaultWorkspace(path);
|
|
defaultWorkspaces.value = [...defaultWorkspaces.value, path];
|
|
}
|
|
}
|
|
|
|
const isCurrentWorkspaceDefault = computed(() => {
|
|
return Boolean(newChatWorkspace.value && defaultWorkspaces.value.includes(newChatWorkspace.value));
|
|
});
|
|
|
|
const showDefaultWorkspaceMenu = ref(false);
|
|
|
|
function getFolderName(path: string | null): string {
|
|
if (!path) return '';
|
|
const parts = path.split('/');
|
|
return parts[parts.length - 1] || path;
|
|
}
|
|
|
|
const mostRecentDefaultWorkspace = computed(() => {
|
|
if (defaultWorkspaces.value.length === 0) return null;
|
|
|
|
// 从最近使用记录中找第一个默认工作区
|
|
const recent = [...recentWorkspaces.value].sort((a, b) => b.lastUsed - a.lastUsed);
|
|
for (const entry of recent) {
|
|
if (defaultWorkspaces.value.includes(entry.path)) {
|
|
return entry.path;
|
|
}
|
|
}
|
|
|
|
// 如果没有使用记录,返回第一个默认工作区
|
|
return defaultWorkspaces.value[0];
|
|
});
|
|
|
|
// 动态计算可见的工作区数量(根据容器宽度)
|
|
const visibleDefaultWorkspaces = computed(() => {
|
|
if (defaultWorkspaces.value.length === 0) return [];
|
|
|
|
// 简单策略:前2个总是可见,其余通过"更多"菜单
|
|
// 未来可以根据实际容器宽度动态调整
|
|
const maxVisible = Math.min(2, defaultWorkspaces.value.length);
|
|
return defaultWorkspaces.value.slice(0, maxVisible);
|
|
});
|
|
|
|
const hasHiddenDefaults = computed(() => {
|
|
return defaultWorkspaces.value.length > visibleDefaultWorkspaces.value.length;
|
|
});
|
|
|
|
const hiddenDefaultWorkspaces = computed(() => {
|
|
const visible = new Set(visibleDefaultWorkspaces.value);
|
|
return defaultWorkspaces.value.filter(ws => !visible.has(ws));
|
|
});
|
|
|
|
function getModelGroupsForProfile(profile: string) {
|
|
const profileModels = appStore.profileModelGroups.find(
|
|
(entry) => entry.profile === profile,
|
|
);
|
|
return profileModels?.groups || [];
|
|
}
|
|
|
|
function getSelectableModelGroupsForProfile(profile: string) {
|
|
return getModelGroupsForProfile(profile);
|
|
}
|
|
|
|
function getDefaultModelForProfile(profile: string) {
|
|
const groups = getSelectableModelGroupsForProfile(profile);
|
|
const activeProfileName = profilesStore.activeProfileName || "default";
|
|
const selectedProvider = appStore.selectedProvider || "";
|
|
const selectedModel = appStore.selectedModel || "";
|
|
const selectedGroup = selectedProvider
|
|
? groups.find((group) => group.provider === selectedProvider)
|
|
: undefined;
|
|
if (
|
|
profile === activeProfileName &&
|
|
selectedGroup?.models.includes(selectedModel)
|
|
) {
|
|
return {
|
|
provider: selectedProvider,
|
|
model: selectedModel,
|
|
};
|
|
}
|
|
const profileModels = appStore.profileModelGroups.find(
|
|
(entry) => entry.profile === profile,
|
|
);
|
|
const defaultProvider = profileModels?.default_provider || "";
|
|
const defaultModel = profileModels?.default || "";
|
|
const providerGroup = defaultProvider
|
|
? groups.find((group) => group.provider === defaultProvider)
|
|
: undefined;
|
|
const fallbackGroup = providerGroup || groups.find((group) => group.models.length > 0);
|
|
return {
|
|
provider: fallbackGroup?.provider || "",
|
|
model: fallbackGroup?.models.includes(defaultModel)
|
|
? defaultModel
|
|
: fallbackGroup?.models[0] || "",
|
|
};
|
|
}
|
|
|
|
const newChatProfileOptions = computed(() =>
|
|
(profilesStore.profiles.length > 0 ? profilesStore.profiles : [{ name: "default" }]).map((profile) => ({
|
|
label: profile.name,
|
|
value: profile.name,
|
|
})),
|
|
);
|
|
|
|
const newChatModelGroups = computed(() => {
|
|
return getSelectableModelGroupsForProfile(newChatProfile.value);
|
|
});
|
|
|
|
const newChatProviderOptions = computed(() =>
|
|
newChatModelGroups.value.map((group) => ({
|
|
label: group.label || group.provider,
|
|
value: group.provider,
|
|
})),
|
|
);
|
|
|
|
const newChatModelOptions = computed(() => {
|
|
const group = newChatModelGroups.value.find(
|
|
(item) => item.provider === newChatProvider.value,
|
|
);
|
|
return (group?.models || []).map((model) => ({
|
|
label: appStore.displayModelName(model, group?.provider),
|
|
value: model,
|
|
}));
|
|
});
|
|
|
|
const selectedNewChatProviderGroup = computed(() =>
|
|
newChatModelGroups.value.find((item) => item.provider === newChatProvider.value),
|
|
);
|
|
|
|
const canConfirmNewChat = computed(() => {
|
|
if (!newChatProfile.value) return false;
|
|
if (!newChatProvider.value || !newChatModel.value) return false;
|
|
return true;
|
|
});
|
|
|
|
function syncNewChatModelSelection() {
|
|
const defaults = getDefaultModelForProfile(newChatProfile.value);
|
|
newChatProvider.value = defaults.provider;
|
|
newChatModel.value = defaults.model;
|
|
}
|
|
|
|
function ensureNewChatProviderSelection() {
|
|
const currentGroup = selectedNewChatProviderGroup.value;
|
|
if (currentGroup && currentGroup.models.includes(newChatModel.value)) {
|
|
return;
|
|
}
|
|
syncNewChatModelSelection();
|
|
}
|
|
|
|
watch(
|
|
() => [newChatProfile.value],
|
|
() => {
|
|
ensureNewChatProviderSelection();
|
|
// Reload workspace data when profile changes
|
|
if (newChatProfile.value) {
|
|
initWorkspaceComposable(newChatProfile.value);
|
|
}
|
|
},
|
|
);
|
|
|
|
async function openNewChatModal() {
|
|
isBatchMode.value = false;
|
|
selectedSessionKeys.value.clear();
|
|
showBatchDeleteConfirm.value = false;
|
|
showNewChatModal.value = true;
|
|
newChatLoading.value = true;
|
|
try {
|
|
if (profilesStore.profiles.length === 0) await profilesStore.fetchProfiles();
|
|
if (appStore.modelGroups.length === 0 && appStore.profileModelGroups.length === 0) {
|
|
await appStore.loadModels();
|
|
}
|
|
newChatProfile.value =
|
|
profilesStore.activeProfileName ||
|
|
profilesStore.profiles.find((profile) => profile.active)?.name ||
|
|
profilesStore.profiles[0]?.name ||
|
|
"default";
|
|
|
|
// Initialize workspace composable and load defaults
|
|
initWorkspaceComposable(newChatProfile.value);
|
|
|
|
// Auto-fill most recent default workspace if available
|
|
if (mostRecentDefaultWorkspace.value) {
|
|
newChatWorkspace.value = mostRecentDefaultWorkspace.value;
|
|
} else {
|
|
newChatWorkspace.value = "";
|
|
}
|
|
|
|
syncNewChatModelSelection();
|
|
} finally {
|
|
newChatLoading.value = false;
|
|
}
|
|
}
|
|
|
|
function handleNewChatProfileChange(selected: unknown) {
|
|
newChatProfile.value = String(selected ?? "");
|
|
syncNewChatModelSelection();
|
|
}
|
|
|
|
function handleNewChatProviderChange(selected: unknown) {
|
|
newChatProvider.value = String(selected ?? "");
|
|
newChatModel.value = newChatModelOptions.value[0]?.value || "";
|
|
}
|
|
|
|
async function confirmNewChat() {
|
|
const session = chatStore.newChat({
|
|
profile: newChatProfile.value,
|
|
provider: newChatProvider.value,
|
|
model: newChatModel.value,
|
|
source: "cli",
|
|
agent: "hermes",
|
|
workspace: newChatWorkspace.value || null,
|
|
});
|
|
// Record workspace to recent list
|
|
if (newChatWorkspace.value && workspaceComposable) {
|
|
workspaceComposable.recordWorkspaceUsage(newChatWorkspace.value);
|
|
recentWorkspaces.value = workspaceComposable.loadRecentWorkspaces();
|
|
}
|
|
|
|
await router.push({
|
|
name: chatStore.runtimeMode === "global_agent" ? "hermes.globalAgentSession" : "hermes.session",
|
|
params: { sessionId: session.id },
|
|
});
|
|
showNewChatModal.value = false;
|
|
}
|
|
|
|
function sessionProfile(sessionId: string): string | null {
|
|
return chatStore.sessions.find((session) => session.id === sessionId)?.profile || null;
|
|
}
|
|
|
|
function buildSessionUrl(sessionId: string, profile?: string | null): string {
|
|
const href = router.resolve({
|
|
name: chatStore.runtimeMode === "global_agent" ? "hermes.globalAgentSession" : "hermes.session",
|
|
params: { sessionId },
|
|
query: profile ? { profile } : undefined,
|
|
}).href;
|
|
return `${window.location.origin}${window.location.pathname}${href}`;
|
|
}
|
|
|
|
async function copySessionLink(id?: string) {
|
|
const sessionId = id || chatStore.activeSessionId;
|
|
if (sessionId) {
|
|
const ok = await copyToClipboard(buildSessionUrl(sessionId, sessionProfile(sessionId)));
|
|
if (ok) message.success(t("common.copied"));
|
|
else message.error(t("common.copied") + " ✗");
|
|
}
|
|
}
|
|
|
|
async function copySessionId(id?: string) {
|
|
const sessionId = id || chatStore.activeSessionId;
|
|
if (sessionId) {
|
|
const ok = await copyToClipboard(sessionId);
|
|
if (ok) message.success(t("common.copied"));
|
|
else message.error(t("common.copied") + " ✗");
|
|
}
|
|
}
|
|
|
|
async function handleDeleteSession(id: string) {
|
|
const ok = await chatStore.deleteSession(id);
|
|
if (!ok) {
|
|
message.error(t("common.deleteFailed"));
|
|
return;
|
|
}
|
|
sessionBrowserPrefsStore.removePinned(id);
|
|
message.success(t("chat.sessionDeleted"));
|
|
}
|
|
|
|
function toggleBatchMode() {
|
|
if (isBatchDeleting.value) return;
|
|
isBatchMode.value = !isBatchMode.value;
|
|
if (!isBatchMode.value) {
|
|
selectedSessionKeys.value.clear();
|
|
showBatchDeleteConfirm.value = false;
|
|
}
|
|
}
|
|
|
|
function sessionSelectionKey(session: Pick<Session, "id" | "profile">): string {
|
|
return `${session.profile || "default"}\u0000${session.id}`;
|
|
}
|
|
|
|
function toggleSessionSelection(session: Session) {
|
|
if (isBatchDeleting.value) return;
|
|
const key = sessionSelectionKey(session);
|
|
if (selectedSessionKeys.value.has(key)) {
|
|
selectedSessionKeys.value.delete(key);
|
|
} else {
|
|
selectedSessionKeys.value.add(key);
|
|
}
|
|
selectedSessionKeys.value = new Set(selectedSessionKeys.value);
|
|
if (selectedSessionKeys.value.size === 0) {
|
|
showBatchDeleteConfirm.value = false;
|
|
}
|
|
}
|
|
|
|
function isSessionSelected(session: Session): boolean {
|
|
return selectedSessionKeys.value.has(sessionSelectionKey(session));
|
|
}
|
|
|
|
async function handleBatchDelete() {
|
|
if (selectedSessionKeys.value.size === 0 || isBatchDeleting.value) return;
|
|
|
|
const sessionsByKey = new Map(chatStore.sessions.map((session) => [sessionSelectionKey(session), session]));
|
|
const targets = Array.from(selectedSessionKeys.value)
|
|
.map((key) => sessionsByKey.get(key))
|
|
.filter((session): session is Session => Boolean(session))
|
|
.map((session) => ({ id: session.id, profile: session.profile || null }));
|
|
if (targets.length === 0) return;
|
|
isBatchDeleting.value = true;
|
|
try {
|
|
const result = await batchDeleteSessions(targets);
|
|
if (result.deleted > 0) {
|
|
// Remove from pinned sessions
|
|
for (const target of targets) {
|
|
sessionBrowserPrefsStore.removePinned(target.id);
|
|
}
|
|
|
|
// Remove deleted sessions from local store (without calling API again)
|
|
// Use loadSessions to refresh from server instead of manual filtering
|
|
await chatStore.loadSessions(chatStore.sessionProfileFilter);
|
|
|
|
message.success(t("chat.batchDeleteSuccess", { count: result.deleted }));
|
|
if (result.failed > 0) {
|
|
message.warning(t("chat.batchDeletePartial", { failed: result.failed }));
|
|
}
|
|
} else {
|
|
message.error(t("chat.batchDeleteFailed"));
|
|
}
|
|
} catch (err: any) {
|
|
message.error(t("chat.batchDeleteFailed"));
|
|
} finally {
|
|
isBatchDeleting.value = false;
|
|
showBatchDeleteConfirm.value = false;
|
|
isBatchMode.value = false;
|
|
selectedSessionKeys.value.clear();
|
|
}
|
|
}
|
|
|
|
function handleBatchDeleteConfirm() {
|
|
void handleBatchDelete();
|
|
return false;
|
|
}
|
|
|
|
function selectAllSessions() {
|
|
if (isBatchDeleting.value) return;
|
|
selectedSessionKeys.value.clear();
|
|
for (const session of chatStore.sessions) {
|
|
if (session.id !== chatStore.activeSessionId) {
|
|
selectedSessionKeys.value.add(sessionSelectionKey(session));
|
|
}
|
|
}
|
|
selectedSessionKeys.value = new Set(selectedSessionKeys.value);
|
|
}
|
|
|
|
const selectedCount = computed(() => selectedSessionKeys.value.size);
|
|
const canSelectAll = computed(() => {
|
|
return chatStore.sessions.some(s => s.id !== chatStore.activeSessionId);
|
|
});
|
|
|
|
const contextSessionId = ref<string | null>(null);
|
|
const contextSessionPinned = computed(() =>
|
|
contextSessionId.value
|
|
? sessionBrowserPrefsStore.isPinned(contextSessionId.value)
|
|
: false,
|
|
);
|
|
const contextSession = computed(() =>
|
|
contextSessionId.value
|
|
? chatStore.sessions.find((session) => session.id === contextSessionId.value) || null
|
|
: null,
|
|
);
|
|
|
|
const contextMenuItems = computed(() => {
|
|
const items: { key: string; content: string; children?: { key: string; content: string; children?: { key: string; content: string }[] }[] }[] = [{
|
|
content: t(contextSessionPinned.value ? "chat.unpin" : "chat.pin"),
|
|
key: "pin",
|
|
},
|
|
{ content: t("chat.rename"), key: "rename" }]
|
|
|
|
if (contextSession.value?.source !== "global_agent") {
|
|
items.push({ content: t("chat.archiveSession"), key: "archive" })
|
|
}
|
|
|
|
items.push({ content: t("chat.setWorkspace"), key: "workspace" })
|
|
|
|
if (contextSession.value?.source === "cli") {
|
|
items.push({ content: t("chat.setModel"), key: "model" })
|
|
}
|
|
|
|
items.push({
|
|
content: t("chat.export"),
|
|
key: "export",
|
|
children: [
|
|
{
|
|
content: t("chat.exportFull"),
|
|
key: "export-full",
|
|
children: [
|
|
{ content: "JSON", key: "export-full-json" },
|
|
{ content: "TXT", key: "export-full-txt" },
|
|
],
|
|
},
|
|
{
|
|
content: t("chat.exportCompressed"),
|
|
key: "export-compressed",
|
|
children: [
|
|
{ content: "JSON", key: "export-compressed-json" },
|
|
{ content: "TXT", key: "export-compressed-txt" },
|
|
],
|
|
},
|
|
],
|
|
})
|
|
items.push({ content: t("chat.openSessionInNewTab"), key: "open-link" })
|
|
items.push({ content: t("chat.copySessionLink"), key: "copy-link" })
|
|
items.push({ content: t("chat.copySessionId"), key: "copy-id" })
|
|
return items
|
|
});
|
|
|
|
function openSettingsPage() {
|
|
router.push({ name: "hermes.settings" });
|
|
}
|
|
|
|
function handleContextMenu(e: MouseEvent, sessionId: string) {
|
|
e.preventDefault();
|
|
contextSessionId.value = sessionId;
|
|
}
|
|
|
|
const showContextMenu = ref(false);
|
|
|
|
function parseExportKey(key: string): { mode: 'full' | 'compressed'; ext: 'json' | 'txt' } | null {
|
|
if (key === 'export-full-json') return { mode: 'full', ext: 'json' }
|
|
if (key === 'export-full-txt') return { mode: 'full', ext: 'txt' }
|
|
if (key === 'export-compressed-json') return { mode: 'compressed', ext: 'json' }
|
|
if (key === 'export-compressed-txt') return { mode: 'compressed', ext: 'txt' }
|
|
return null
|
|
}
|
|
|
|
async function handleContextMenuSelect(selected: string | number | Record<string, any> | undefined) {
|
|
const key = typeof selected === "object" ? String(selected?.key ?? "") : String(selected ?? "");
|
|
showContextMenu.value = false;
|
|
if (!contextSessionId.value) return;
|
|
if (key === "pin") {
|
|
sessionBrowserPrefsStore.togglePinned(contextSessionId.value);
|
|
return;
|
|
}
|
|
if (key === "copy-link") {
|
|
copySessionLink(contextSessionId.value);
|
|
} else if (key === "copy-id") {
|
|
copySessionId(contextSessionId.value);
|
|
} else if (key === "open-link") {
|
|
openSessionInNewTab(contextSessionId.value);
|
|
} else if (key === "archive") {
|
|
const archivedSession = contextSession.value;
|
|
const ok = await chatStore.archiveSession(contextSessionId.value);
|
|
if (ok) {
|
|
sessionBrowserPrefsStore.removePinned(contextSessionId.value);
|
|
if (archivedSession) {
|
|
selectedSessionKeys.value.delete(sessionSelectionKey(archivedSession));
|
|
selectedSessionKeys.value = new Set(selectedSessionKeys.value);
|
|
}
|
|
message.success(t("chat.sessionArchived"));
|
|
} else {
|
|
message.error(t("chat.archiveSessionFailed"));
|
|
}
|
|
} else if (parseExportKey(key)) {
|
|
const { mode, ext } = parseExportKey(key)!;
|
|
const loadingMsg = mode === "compressed" ? message.loading(t("chat.exportCompressing"), { duration: 0 }) : null;
|
|
try {
|
|
await exportSession(contextSessionId.value, mode, ext);
|
|
loadingMsg?.destroy();
|
|
message.success(t("chat.exportSuccess"));
|
|
} catch {
|
|
loadingMsg?.destroy();
|
|
message.error(t("chat.exportFailed"));
|
|
}
|
|
} else if (key === "workspace") {
|
|
const session = chatStore.sessions.find(
|
|
(s) => s.id === contextSessionId.value,
|
|
);
|
|
workspaceSessionId.value = contextSessionId.value;
|
|
workspaceValue.value = session?.workspace || "";
|
|
showWorkspaceModal.value = true;
|
|
} else if (key === "model") {
|
|
await openSessionModelModal(contextSessionId.value);
|
|
} else if (key === "rename") {
|
|
const session = chatStore.sessions.find(
|
|
(s) => s.id === contextSessionId.value,
|
|
);
|
|
renameSessionId.value = contextSessionId.value;
|
|
renameValue.value = session?.title || "";
|
|
showRenameModal.value = true;
|
|
nextTick(() => {
|
|
renameInputRef.value?.focus();
|
|
});
|
|
}
|
|
}
|
|
|
|
async function handleRenameConfirm() {
|
|
if (!renameSessionId.value || !renameValue.value.trim()) return;
|
|
const ok = await renameSession(
|
|
renameSessionId.value,
|
|
renameValue.value.trim(),
|
|
);
|
|
if (ok) {
|
|
const session = chatStore.sessions.find(
|
|
(s) => s.id === renameSessionId.value,
|
|
);
|
|
if (session) session.title = renameValue.value.trim();
|
|
if (chatStore.activeSession?.id === renameSessionId.value) {
|
|
chatStore.activeSession.title = renameValue.value.trim();
|
|
}
|
|
message.success(t("chat.renamed"));
|
|
} else {
|
|
message.error(t("chat.renameFailed"));
|
|
}
|
|
showRenameModal.value = false;
|
|
}
|
|
|
|
const showWorkspaceModal = ref(false);
|
|
const workspaceValue = ref("");
|
|
const workspaceSessionId = ref<string | null>(null);
|
|
|
|
function openActiveSessionWorkspace() {
|
|
const session = chatStore.activeSession;
|
|
if (!session?.id) return;
|
|
workspaceSessionId.value = session.id;
|
|
workspaceValue.value = session.workspace || "";
|
|
showWorkspaceModal.value = true;
|
|
}
|
|
|
|
async function handleWorkspaceConfirm() {
|
|
if (!workspaceSessionId.value) return;
|
|
const ok = await setSessionWorkspace(
|
|
workspaceSessionId.value,
|
|
workspaceValue.value || null,
|
|
);
|
|
if (ok) {
|
|
const session = chatStore.sessions.find(
|
|
(s) => s.id === workspaceSessionId.value,
|
|
);
|
|
if (session) session.workspace = workspaceValue.value || null;
|
|
if (chatStore.activeSession?.id === workspaceSessionId.value) {
|
|
chatStore.activeSession.workspace = workspaceValue.value || null;
|
|
}
|
|
message.success(t("chat.workspaceSet"));
|
|
} else {
|
|
message.error(t("chat.workspaceSetFailed"));
|
|
}
|
|
showWorkspaceModal.value = false;
|
|
}
|
|
|
|
const showSessionModelModal = ref(false);
|
|
const sessionModelSessionId = ref<string | null>(null);
|
|
const sessionModelSearch = ref("");
|
|
const sessionModelCollapsedGroups = ref<Record<string, boolean>>({});
|
|
const sessionModelValue = ref("");
|
|
const sessionModelProvider = ref("");
|
|
const sessionModelCustomInput = ref("");
|
|
const sessionModelCustomProvider = ref("");
|
|
|
|
const sessionModelProfile = computed<string | null>(() => {
|
|
const session = chatStore.sessions.find((s) => s.id === sessionModelSessionId.value);
|
|
return session?.profile || null;
|
|
});
|
|
|
|
const sessionModelBaseGroups = computed(() =>
|
|
sessionModelProfile.value ? getModelGroupsForProfile(sessionModelProfile.value) : [],
|
|
);
|
|
|
|
const sessionModelProviderOptions = computed(() =>
|
|
sessionModelBaseGroups.value.map((group) => ({ label: group.label, value: group.provider })),
|
|
);
|
|
|
|
const sessionModelGroupsWithCustom = computed(() =>
|
|
sessionModelBaseGroups.value.map((group) => ({
|
|
...group,
|
|
models: [
|
|
...group.models,
|
|
...(appStore.customModels[group.provider] || []).filter(
|
|
(model) => !group.models.includes(model),
|
|
),
|
|
],
|
|
})),
|
|
);
|
|
|
|
const filteredSessionModelGroups = computed(() => {
|
|
const query = sessionModelSearch.value.trim().toLowerCase();
|
|
if (!query) return sessionModelGroupsWithCustom.value;
|
|
return sessionModelGroupsWithCustom.value
|
|
.map((group) => ({
|
|
...group,
|
|
models: group.models.filter((model) => {
|
|
const displayName = appStore.displayModelName(model, group.provider);
|
|
return model.toLowerCase().includes(query) || displayName.toLowerCase().includes(query);
|
|
}),
|
|
}))
|
|
.filter((group) => group.models.length > 0 || group.label.toLowerCase().includes(query));
|
|
});
|
|
|
|
async function openSessionModelModal(sessionId: string) {
|
|
if (appStore.modelGroups.length === 0 && appStore.profileModelGroups.length === 0) {
|
|
await appStore.loadModels();
|
|
}
|
|
const session =
|
|
chatStore.sessions.find((s) => s.id === sessionId) ||
|
|
(chatStore.activeSession?.id === sessionId ? chatStore.activeSession : undefined);
|
|
sessionModelSessionId.value = sessionId;
|
|
const groups = sessionModelBaseGroups.value;
|
|
const providerGroup = session?.provider
|
|
? groups.find((group) => group.provider === session.provider)
|
|
: undefined;
|
|
const fallbackGroup = providerGroup || groups.find((group) => group.models.length > 0);
|
|
const defaults = {
|
|
provider: fallbackGroup?.provider || "",
|
|
model: fallbackGroup?.models.includes(session?.model || "")
|
|
? session?.model || ""
|
|
: fallbackGroup?.models[0] || "",
|
|
};
|
|
sessionModelValue.value = providerGroup ? session?.model || defaults.model || "" : defaults.model || "";
|
|
sessionModelProvider.value = providerGroup ? session?.provider || "" : defaults.provider || "";
|
|
sessionModelCustomProvider.value = sessionModelProvider.value;
|
|
sessionModelSearch.value = "";
|
|
sessionModelCustomInput.value = "";
|
|
sessionModelCollapsedGroups.value = {};
|
|
showSessionModelModal.value = true;
|
|
}
|
|
|
|
function handleHeaderModelClick() {
|
|
const sessionId = chatStore.activeSession?.id;
|
|
if (!sessionId) {
|
|
openNewChatModal();
|
|
return;
|
|
}
|
|
openSessionModelModal(sessionId);
|
|
}
|
|
|
|
function isSessionModelGroupCollapsed(provider: string) {
|
|
return !!sessionModelCollapsedGroups.value[provider];
|
|
}
|
|
|
|
function toggleSessionModelGroup(provider: string) {
|
|
sessionModelCollapsedGroups.value[provider] = !sessionModelCollapsedGroups.value[provider];
|
|
}
|
|
|
|
function isCustomSessionModel(model: string, provider: string) {
|
|
return (appStore.customModels[provider] || []).includes(model);
|
|
}
|
|
|
|
function sessionModelDisplayName(model: string, provider: string) {
|
|
return appStore.displayModelName(model, provider);
|
|
}
|
|
|
|
function sessionModelAlias(model: string, provider: string) {
|
|
return appStore.getModelAlias(model, provider);
|
|
}
|
|
|
|
async function applySessionModelSwitch(model: string, provider: string) {
|
|
if (!sessionModelSessionId.value) return;
|
|
const ok = await chatStore.switchSessionModel(model, provider, sessionModelSessionId.value);
|
|
if (ok) {
|
|
sessionModelValue.value = model;
|
|
sessionModelProvider.value = provider;
|
|
showSessionModelModal.value = false;
|
|
message.success(t("chat.modelSet"));
|
|
} else {
|
|
message.error(t("chat.modelSetFailed"));
|
|
}
|
|
}
|
|
|
|
async function selectSessionModel(model: string, provider: string) {
|
|
const meta = sessionModelBaseGroups.value.find((group) => group.provider === provider)?.model_meta?.[model];
|
|
if (meta?.disabled || !sessionModelSessionId.value) return;
|
|
await applySessionModelSwitch(model, provider);
|
|
}
|
|
|
|
async function handleSessionModelCustomSubmit() {
|
|
const model = sessionModelCustomInput.value.trim();
|
|
const provider = sessionModelCustomProvider.value;
|
|
if (!model || !provider) return;
|
|
await selectSessionModel(model, provider);
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="chat-panel">
|
|
<div
|
|
v-if="currentMode === 'chat'"
|
|
class="session-backdrop"
|
|
:class="{ active: showSessions }"
|
|
@click="showSessions = false"
|
|
/>
|
|
<aside
|
|
v-if="currentMode === 'chat'"
|
|
class="session-list"
|
|
:class="{ collapsed: !showSessions }"
|
|
>
|
|
<div v-if="showSessions" class="page-sidebar-top">
|
|
<PageSidebarNav
|
|
:active="chatStore.runtimeMode === 'global_agent' ? 'global' : 'chat'"
|
|
:primary-label="t('chat.newChat')"
|
|
@primary="openNewChatModal"
|
|
/>
|
|
<div class="session-list-toolbar">
|
|
<a-select
|
|
class="session-profile-filter"
|
|
:model-value="sessionProfileFilter || '__all__'"
|
|
:options="profileFilterOptions"
|
|
size="small"
|
|
:loading="profilesStore.loading"
|
|
@change="handleProfileFilterChange"
|
|
/>
|
|
<div class="session-list-actions">
|
|
<button class="session-close-btn" @click="showSessions = false">
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<line x1="18" y1="6" x2="6" y2="18" />
|
|
<line x1="6" y1="6" x2="18" y2="18" />
|
|
</svg>
|
|
</button>
|
|
<a-button
|
|
v-if="!isBatchMode"
|
|
type="secondary"
|
|
size="mini"
|
|
@click="toggleBatchMode"
|
|
:title="t('chat.toggleBatchMode')"
|
|
>
|
|
<template #icon>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path d="M9 11l3 3L22 4" />
|
|
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
|
</svg>
|
|
</template>
|
|
</a-button>
|
|
<a-button
|
|
v-if="isBatchMode"
|
|
type="secondary"
|
|
size="mini"
|
|
@click="selectAllSessions"
|
|
:disabled="!canSelectAll || isBatchDeleting"
|
|
:title="t('chat.selectAll')"
|
|
>
|
|
<template #icon>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path d="M9 11l3 3L22 4" />
|
|
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
|
</svg>
|
|
</template>
|
|
</a-button>
|
|
<a-popconfirm
|
|
v-if="isBatchMode && selectedCount > 0"
|
|
:visible="showBatchDeleteConfirm"
|
|
:ok-button-props="{ loading: isBatchDeleting, disabled: isBatchDeleting }"
|
|
:cancel-button-props="{ disabled: isBatchDeleting }"
|
|
@ok="handleBatchDeleteConfirm"
|
|
@cancel="showBatchDeleteConfirm = false"
|
|
@update:visible="showBatchDeleteConfirm = $event"
|
|
>
|
|
<a-button type="secondary" size="mini" status="danger" :loading="isBatchDeleting" :disabled="isBatchDeleting">
|
|
<template #icon>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<polyline points="3 6 5 6 21 6" />
|
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
</svg>
|
|
</template>
|
|
</a-button>
|
|
{{ t('chat.confirmBatchDelete', { count: selectedCount }) }}
|
|
</a-popconfirm>
|
|
<a-button
|
|
v-if="isBatchMode"
|
|
type="secondary"
|
|
size="mini"
|
|
@click="toggleBatchMode"
|
|
:disabled="isBatchDeleting"
|
|
>
|
|
<template #icon>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<line x1="18" y1="6" x2="6" y2="18" />
|
|
<line x1="6" y1="6" x2="18" y2="18" />
|
|
</svg>
|
|
</template>
|
|
</a-button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<a-dropdown
|
|
v-if="showSessions"
|
|
trigger="contextMenu"
|
|
align-point
|
|
position="bl"
|
|
v-model:popup-visible="showContextMenu"
|
|
@select="handleContextMenuSelect"
|
|
>
|
|
<div class="session-items">
|
|
<div
|
|
v-if="chatStore.isLoadingSessions && chatStore.sessions.length === 0"
|
|
class="session-loading"
|
|
>
|
|
{{ t("common.loading") }}
|
|
</div>
|
|
<div v-else-if="chatStore.sessions.length === 0" class="session-empty">
|
|
{{ t("chat.noSessions") }}
|
|
</div>
|
|
|
|
<template v-if="pinnedSessions.length > 0">
|
|
<div class="session-group-header session-group-header--static">
|
|
<span class="session-group-label">{{ t("chat.pinned") }}</span>
|
|
<span class="session-group-count">{{ pinnedSessions.length }}</span>
|
|
</div>
|
|
<SessionListItem
|
|
v-for="s in pinnedSessions"
|
|
:key="`pinned-${s.id}`"
|
|
:session="s"
|
|
:active="s.id === chatStore.activeSessionId"
|
|
:pinned="true"
|
|
:can-delete="
|
|
s.id !== chatStore.activeSessionId ||
|
|
chatStore.sessions.length > 1
|
|
"
|
|
:streaming="chatStore.isSessionLive(s.id)"
|
|
:completed-unread="chatStore.isSessionCompletedUnread(s.id)"
|
|
:selectable="isBatchMode"
|
|
:selected="isSessionSelected(s)"
|
|
:show-profile="true"
|
|
:to="sessionHref(s.id)"
|
|
@select="handleSessionClick(s.id)"
|
|
@contextmenu="handleContextMenu($event, s.id)"
|
|
@delete="handleDeleteSession(s.id)"
|
|
@toggle-select="toggleSessionSelection(s)"
|
|
/>
|
|
</template>
|
|
|
|
<SessionListItem
|
|
v-for="s in unpinnedSessions"
|
|
:key="s.id"
|
|
:session="s"
|
|
:active="s.id === chatStore.activeSessionId"
|
|
:pinned="false"
|
|
:can-delete="
|
|
s.id !== chatStore.activeSessionId ||
|
|
chatStore.sessions.length > 1
|
|
"
|
|
:streaming="chatStore.isSessionLive(s.id)"
|
|
:completed-unread="chatStore.isSessionCompletedUnread(s.id)"
|
|
:selectable="isBatchMode"
|
|
:selected="isSessionSelected(s)"
|
|
:show-profile="true"
|
|
:to="sessionHref(s.id)"
|
|
@select="handleSessionClick(s.id)"
|
|
@contextmenu="handleContextMenu($event, s.id)"
|
|
@delete="handleDeleteSession(s.id)"
|
|
@toggle-select="toggleSessionSelection(s)"
|
|
/>
|
|
</div>
|
|
<template #content>
|
|
<template v-for="item in contextMenuItems" :key="item.key">
|
|
<a-dsubmenu v-if="item.children" :value="item.key">
|
|
{{ item.content }}
|
|
<template #content>
|
|
<template v-for="child in item.children" :key="child.key">
|
|
<a-dsubmenu v-if="child.children" :value="child.key">
|
|
{{ child.content }}
|
|
<template #content>
|
|
<a-doption
|
|
v-for="leaf in child.children"
|
|
:key="leaf.key"
|
|
:value="leaf.key"
|
|
>
|
|
{{ leaf.content }}
|
|
</a-doption>
|
|
</template>
|
|
</a-dsubmenu>
|
|
<a-doption v-else :key="child.key" :value="child.key">
|
|
{{ child.content }}
|
|
</a-doption>
|
|
</template>
|
|
</template>
|
|
</a-dsubmenu>
|
|
<a-doption v-else :value="item.key">
|
|
{{ item.content }}
|
|
</a-doption>
|
|
</template>
|
|
</template>
|
|
</a-dropdown>
|
|
<div v-if="showSessions" class="page-sidebar-bottom">
|
|
<button class="page-sidebar-menu-btn" type="button" @click="openSettingsPage">
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="1.8"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<circle cx="12" cy="12" r="3" />
|
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
|
</svg>
|
|
<span>{{ t("sidebar.settings") }}</span>
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
|
|
<a-modal
|
|
v-model:visible="showRenameModal"
|
|
:title="t('chat.renameSession')"
|
|
:width="400"
|
|
unmount-on-close
|
|
>
|
|
<a-input
|
|
ref="renameInputRef"
|
|
v-model="renameValue"
|
|
:placeholder="t('chat.enterNewTitle')"
|
|
@keydown.enter="handleRenameConfirm"
|
|
/>
|
|
<template #footer>
|
|
<a-space justify="end">
|
|
<a-button @click="showRenameModal = false">{{ t("common.cancel") }}</a-button>
|
|
<a-button type="primary" @click="handleRenameConfirm">{{ t("common.ok") }}</a-button>
|
|
</a-space>
|
|
</template>
|
|
</a-modal>
|
|
|
|
<a-modal
|
|
v-model:visible="showWorkspaceModal"
|
|
:title="t('chat.setWorkspaceTitle')"
|
|
:width="520"
|
|
unmount-on-close
|
|
>
|
|
<FolderPicker v-model="workspaceValue" />
|
|
<template #footer>
|
|
<a-space justify="end">
|
|
<a-button @click="showWorkspaceModal = false">{{ t("common.cancel") }}</a-button>
|
|
<a-button type="primary" @click="handleWorkspaceConfirm">{{ t("common.ok") }}</a-button>
|
|
</a-space>
|
|
</template>
|
|
</a-modal>
|
|
|
|
<a-modal
|
|
v-model:visible="showSessionModelModal"
|
|
:title="t('chat.setModelTitle')"
|
|
width="min(480px, calc(100vw - 32px))"
|
|
:mask-closable="true"
|
|
:footer="false"
|
|
unmount-on-close
|
|
>
|
|
<a-input
|
|
v-model="sessionModelSearch"
|
|
:placeholder="t('models.searchPlaceholder')"
|
|
allow-clear
|
|
size="small"
|
|
class="session-model-search"
|
|
/>
|
|
<div class="session-model-list">
|
|
<div v-for="group in filteredSessionModelGroups" :key="group.provider" class="session-model-group">
|
|
<div class="session-model-group-header" @click="toggleSessionModelGroup(group.provider)">
|
|
<svg
|
|
class="session-model-group-arrow"
|
|
:class="{ collapsed: isSessionModelGroupCollapsed(group.provider) }"
|
|
width="12"
|
|
height="12"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<polyline points="6 9 12 15 18 9" />
|
|
</svg>
|
|
<span class="session-model-group-label">{{ group.label }}</span>
|
|
<span class="session-model-group-count">{{ group.models.length }}</span>
|
|
</div>
|
|
<div v-show="!isSessionModelGroupCollapsed(group.provider)" class="session-model-group-items">
|
|
<div
|
|
v-for="model in group.models"
|
|
:key="model"
|
|
class="session-model-item"
|
|
:class="{
|
|
active: model === sessionModelValue && group.provider === sessionModelProvider,
|
|
disabled: !!group.model_meta?.[model]?.disabled,
|
|
}"
|
|
:title="group.model_meta?.[model]?.disabled ? t('models.disabledTooltip') : ''"
|
|
@click="selectSessionModel(model, group.provider)"
|
|
>
|
|
<span class="session-model-item-label">
|
|
<span class="session-model-item-name">{{ sessionModelDisplayName(model, group.provider) }}</span>
|
|
<span v-if="sessionModelAlias(model, group.provider)" class="session-model-item-id">
|
|
{{ t('models.aliasCanonical', { model }) }}
|
|
</span>
|
|
</span>
|
|
<span v-if="group.model_meta?.[model]?.preview" class="session-model-badge-preview">{{ t('models.previewBadge') }}</span>
|
|
<span v-if="group.model_meta?.[model]?.disabled" class="session-model-badge-disabled">{{ t('models.disabledBadge') }}</span>
|
|
<span v-if="isCustomSessionModel(model, group.provider)" class="session-model-badge-custom">{{ t('models.customBadge') }}</span>
|
|
<svg
|
|
v-if="model === sessionModelValue && group.provider === sessionModelProvider"
|
|
class="session-model-check"
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2.5"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<polyline points="20 6 9 17 4 12" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="filteredSessionModelGroups.length === 0" class="session-model-empty">
|
|
{{ sessionModelSearch ? 'No results' : 'No models' }}
|
|
</div>
|
|
<div class="session-model-custom">
|
|
<div class="session-model-custom-row">
|
|
<a-select
|
|
v-model="sessionModelCustomProvider"
|
|
:options="sessionModelProviderOptions"
|
|
size="small"
|
|
class="session-model-custom-provider"
|
|
/>
|
|
<a-input
|
|
v-model="sessionModelCustomInput"
|
|
:placeholder="t('models.customModelPlaceholder')"
|
|
size="small"
|
|
class="session-model-custom-input"
|
|
@keydown.enter="handleSessionModelCustomSubmit"
|
|
/>
|
|
</div>
|
|
<div class="session-model-custom-hint">
|
|
{{ t('models.customModelHint') }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</a-modal>
|
|
|
|
<a-drawer
|
|
v-model:visible="showNewChatModal"
|
|
class="new-chat-drawer"
|
|
placement="right"
|
|
width="min(440px, 100vw)"
|
|
:mask-closable="true"
|
|
unmount-on-close
|
|
>
|
|
<template #title>{{ t('chat.newChat') }}</template>
|
|
<div class="new-chat-form">
|
|
<label class="new-chat-field">
|
|
<span class="new-chat-label">{{ t("sidebar.profiles") }}</span>
|
|
<a-select
|
|
:model-value="newChatProfile"
|
|
:options="newChatProfileOptions"
|
|
:loading="newChatLoading || profilesStore.loading"
|
|
@change="handleNewChatProfileChange"
|
|
/>
|
|
</label>
|
|
<label class="new-chat-field">
|
|
<span class="new-chat-label">{{ t("models.provider") }}</span>
|
|
<a-select
|
|
:model-value="newChatProvider"
|
|
:options="newChatProviderOptions"
|
|
:disabled="newChatLoading"
|
|
@change="handleNewChatProviderChange"
|
|
/>
|
|
</label>
|
|
<label class="new-chat-field">
|
|
<span class="new-chat-label">{{ t("models.models") }}</span>
|
|
<a-select
|
|
v-model="newChatModel"
|
|
:options="newChatModelOptions"
|
|
:disabled="newChatLoading || !newChatProvider"
|
|
allow-search
|
|
allow-clear
|
|
/>
|
|
</label>
|
|
<div class="new-chat-field">
|
|
<span class="new-chat-label">
|
|
{{ t("chat.workspace") }}
|
|
<a-tooltip v-if="isCurrentWorkspaceDefault">
|
|
<span class="workspace-default-badge">{{ t("chat.workspaceDefault") }}</span>
|
|
{{ t("chat.workspaceDefaultTooltip") }}
|
|
</a-tooltip>
|
|
</span>
|
|
<!-- Default workspace chips -->
|
|
<div v-if="defaultWorkspaces.length > 0" class="default-workspace-chips">
|
|
<span class="default-workspace-label">{{ t("chat.defaultWorkspace") }}:</span>
|
|
<div class="workspace-chips-container">
|
|
<template v-for="(ws, index) in visibleDefaultWorkspaces" :key="ws">
|
|
<div
|
|
class="workspace-chip"
|
|
:class="{ active: newChatWorkspace === ws }"
|
|
@click="handleSelectDefaultWorkspace(ws)"
|
|
:title="ws"
|
|
>
|
|
{{ getFolderName(ws) }}
|
|
</div>
|
|
<span v-if="index < visibleDefaultWorkspaces.length - 1 || hasHiddenDefaults" class="workspace-chip-separator">/</span>
|
|
</template>
|
|
<div v-if="hasHiddenDefaults" class="workspace-chip-dropdown">
|
|
<button class="workspace-chip-more" @click="showDefaultWorkspaceMenu = !showDefaultWorkspaceMenu">
|
|
{{ t("chat.more") }} ▼
|
|
</button>
|
|
<div v-if="showDefaultWorkspaceMenu" class="workspace-dropdown-menu">
|
|
<div
|
|
v-for="ws in hiddenDefaultWorkspaces"
|
|
:key="ws"
|
|
class="workspace-dropdown-item"
|
|
@click="handleSelectDefaultWorkspace(ws)"
|
|
:title="ws"
|
|
>
|
|
{{ getFolderName(ws) }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<FolderPicker
|
|
v-model="newChatWorkspace"
|
|
show-favorite
|
|
:favorite="isCurrentWorkspaceDefault"
|
|
:favorite-disabled="!newChatWorkspace"
|
|
:favorite-title="isCurrentWorkspaceDefault ? t('chat.workspaceUnpin') : t('chat.workspacePin')"
|
|
@toggle-favorite="handleToggleDefaultWorkspace"
|
|
/>
|
|
<div v-if="recentWorkspaces.length > 0" class="recent-workspaces">
|
|
<span class="recent-workspaces-label">{{ t("chat.workspaceRecent") }}:</span>
|
|
<div class="recent-workspaces-chips">
|
|
<a-button
|
|
v-for="ws in recentWorkspaces"
|
|
:key="ws.path"
|
|
size="mini"
|
|
:type="ws.path === newChatWorkspace ? 'primary' : 'secondary'"
|
|
@click="handleSelectRecentWorkspace(ws.path)"
|
|
>
|
|
<template #icon>
|
|
<span
|
|
v-if="defaultWorkspaces.includes(ws.path)"
|
|
class="recent-pin-icon"
|
|
@click.stop="handleTogglePinRecent(ws.path)"
|
|
:title="t('chat.workspaceUnpin')"
|
|
>★</span>
|
|
<span
|
|
v-else
|
|
class="recent-pin-icon"
|
|
@click.stop="handleTogglePinRecent(ws.path)"
|
|
:title="t('chat.workspacePin')"
|
|
>☆</span>
|
|
</template>
|
|
{{ getFolderName(ws.path) }}
|
|
</a-button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<template #footer>
|
|
<div class="new-chat-actions">
|
|
<a-button @click="showNewChatModal = false">{{ t("common.cancel") }}</a-button>
|
|
<a-button
|
|
type="primary"
|
|
:disabled="!canConfirmNewChat"
|
|
@click="confirmNewChat"
|
|
>
|
|
{{ t("common.create") }}
|
|
</a-button>
|
|
</div>
|
|
</template>
|
|
</a-drawer>
|
|
|
|
<div class="chat-main">
|
|
<header class="chat-header">
|
|
<div class="header-left">
|
|
<a-button
|
|
v-if="currentMode === 'chat'"
|
|
class="header-sidebar-toggle"
|
|
type="secondary"
|
|
size="small"
|
|
@click="showSessions = !showSessions"
|
|
shape="circle"
|
|
>
|
|
<template #icon>
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
>
|
|
<rect x="3" y="3" width="7" height="7" />
|
|
<rect x="14" y="3" width="7" height="7" />
|
|
<rect x="3" y="14" width="7" height="7" />
|
|
<rect x="14" y="14" width="7" height="7" />
|
|
</svg>
|
|
</template>
|
|
</a-button>
|
|
<span class="header-session-title">{{ headerTitle }}</span>
|
|
<button
|
|
v-if="chatStore.activeSession?.workspace"
|
|
class="workspace-badge"
|
|
type="button"
|
|
:title="chatStore.activeSession.workspace"
|
|
@click="openActiveSessionWorkspace"
|
|
>
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="M3 7a2 2 0 0 1 2-2h5l2 2h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
|
</svg>
|
|
<span>
|
|
{{
|
|
chatStore.activeSession.workspace.split("/").pop() ||
|
|
chatStore.activeSession.workspace
|
|
}}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
<div class="header-actions">
|
|
<!-- chat/live mode toggle hidden -->
|
|
<template v-if="currentMode === 'chat'">
|
|
<a-tooltip v-if="isSuperAdmin" trigger="hover">
|
|
<a-button
|
|
class="header-tool-toggle"
|
|
:class="{ active: showToolPanel }"
|
|
type="secondary"
|
|
size="small"
|
|
@click="showToolPanel = !showToolPanel"
|
|
shape="circle"
|
|
>
|
|
<template #icon>
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
>
|
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
|
<line x1="9" y1="3" x2="9" y2="21" />
|
|
<line x1="15" y1="3" x2="15" y2="21" />
|
|
</svg>
|
|
</template>
|
|
</a-button>
|
|
{{ t("drawer.files") }} / {{ t("drawer.terminal") }}
|
|
</a-tooltip>
|
|
<a-tooltip trigger="hover">
|
|
<a-button
|
|
type="secondary"
|
|
size="small"
|
|
@click="showOutline = !showOutline"
|
|
shape="circle"
|
|
>
|
|
<template #icon>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
>
|
|
<path d="M3 12h18M3 6h18M3 18h18" />
|
|
</svg>
|
|
</template>
|
|
</a-button>
|
|
{{ t("chat.outlineTitle") }}
|
|
</a-tooltip>
|
|
<a-tooltip trigger="hover">
|
|
<a-button
|
|
type="secondary"
|
|
size="small"
|
|
@click="copySessionId()"
|
|
shape="circle"
|
|
>
|
|
<template #icon>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
>
|
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
|
<path
|
|
d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"
|
|
/>
|
|
</svg>
|
|
</template>
|
|
</a-button>
|
|
{{ t("chat.copySessionId") }}
|
|
</a-tooltip>
|
|
</template>
|
|
</div>
|
|
</header>
|
|
|
|
<template v-if="currentMode === 'chat'">
|
|
<div
|
|
ref="chatContentWrapperRef"
|
|
class="chat-content-wrapper"
|
|
:class="{ 'chat-content-wrapper--drop-active': isChatDropActive }"
|
|
@dragover="handleChatDragOver"
|
|
@dragenter="handleChatDragEnter"
|
|
@dragleave="handleChatDragLeave"
|
|
@drop="handleChatDrop"
|
|
>
|
|
<div class="chat-main-content">
|
|
<MessageList ref="messageListRef" />
|
|
<ChatInput
|
|
ref="chatInputRef"
|
|
:model-label="activeSessionModelLabel"
|
|
@model-click="handleHeaderModelClick"
|
|
/>
|
|
</div>
|
|
<OutlinePanel
|
|
v-if="showOutline"
|
|
:messages="chatStore.messages"
|
|
@navigate="handleOutlineNavigate"
|
|
/>
|
|
<aside
|
|
v-if="showToolPanel"
|
|
class="chat-tool-panel"
|
|
:style="toolPanelStyle"
|
|
>
|
|
<div
|
|
class="chat-tool-resize-handle"
|
|
@pointerdown="startToolResize"
|
|
/>
|
|
<div class="chat-tool-panel-inner">
|
|
<div class="chat-tool-tabs" role="tablist">
|
|
<button
|
|
class="chat-tool-tab"
|
|
:class="{ active: activeToolPanel === 'files' }"
|
|
type="button"
|
|
role="tab"
|
|
:aria-selected="activeToolPanel === 'files'"
|
|
@click="activeToolPanel = 'files'"
|
|
>
|
|
{{ t("drawer.files") }}
|
|
</button>
|
|
<button
|
|
class="chat-tool-tab"
|
|
:class="{ active: activeToolPanel === 'terminal' }"
|
|
type="button"
|
|
role="tab"
|
|
:aria-selected="activeToolPanel === 'terminal'"
|
|
@click="activeToolPanel = 'terminal'"
|
|
>
|
|
{{ t("drawer.terminal") }}
|
|
</button>
|
|
</div>
|
|
<div class="chat-tool-content">
|
|
<FilesPanel
|
|
v-show="activeToolPanel === 'files'"
|
|
:workspace-session-id="activeWorkspaceSessionId"
|
|
:workspace="activeWorkspacePath"
|
|
/>
|
|
<TerminalPanel
|
|
v-show="activeToolPanel === 'terminal'"
|
|
:visible="showToolPanel && activeToolPanel === 'terminal'"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
</template>
|
|
<ConversationMonitorPane
|
|
v-else
|
|
:human-only="sessionBrowserPrefsStore.humanOnly"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped lang="scss">
|
|
@use "@/styles/variables" as *;
|
|
|
|
.chat-panel {
|
|
display: flex;
|
|
height: 100%;
|
|
position: relative;
|
|
min-width: 0;
|
|
max-width: 100%;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.session-model-search {
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.session-model-list {
|
|
max-height: 50vh;
|
|
overflow-y: auto;
|
|
scrollbar-width: thin;
|
|
}
|
|
|
|
.session-model-group {
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.session-model-group-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 8px;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: $text-secondary;
|
|
cursor: pointer;
|
|
border-radius: $radius-sm;
|
|
user-select: none;
|
|
transition: background-color $transition-fast;
|
|
|
|
&:hover {
|
|
background-color: $bg-secondary;
|
|
}
|
|
}
|
|
|
|
.session-model-group-arrow {
|
|
flex-shrink: 0;
|
|
transition: transform $transition-fast;
|
|
|
|
&.collapsed {
|
|
transform: rotate(-90deg);
|
|
}
|
|
}
|
|
|
|
.session-model-group-label {
|
|
flex: 1;
|
|
}
|
|
|
|
.session-model-group-count {
|
|
font-size: 11px;
|
|
color: $text-muted;
|
|
font-weight: 400;
|
|
}
|
|
|
|
.session-model-group-items {
|
|
padding-left: 8px;
|
|
}
|
|
|
|
.session-model-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 7px 10px;
|
|
font-size: 13px;
|
|
color: $text-secondary;
|
|
border-radius: $radius-sm;
|
|
cursor: pointer;
|
|
transition: all $transition-fast;
|
|
|
|
&:hover {
|
|
background-color: rgba(var(--accent-primary-rgb), 0.06);
|
|
color: $text-primary;
|
|
}
|
|
|
|
&.active {
|
|
color: $accent-primary;
|
|
font-weight: 500;
|
|
}
|
|
|
|
&.disabled {
|
|
opacity: 0.45;
|
|
cursor: not-allowed;
|
|
|
|
&:hover {
|
|
background-color: transparent;
|
|
color: $text-secondary;
|
|
}
|
|
}
|
|
}
|
|
|
|
.session-model-item-label {
|
|
flex: 1;
|
|
min-width: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
|
|
.session-model-item-name,
|
|
.session-model-item-id {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
font-family: $font-code;
|
|
}
|
|
|
|
.session-model-item-name {
|
|
font-size: 12px;
|
|
}
|
|
|
|
.session-model-item-id {
|
|
color: $text-muted;
|
|
font-size: 10px;
|
|
font-weight: 400;
|
|
}
|
|
|
|
.session-model-check {
|
|
flex-shrink: 0;
|
|
color: $accent-primary;
|
|
}
|
|
|
|
.session-model-badge-preview,
|
|
.session-model-badge-custom,
|
|
.session-model-badge-disabled {
|
|
flex-shrink: 0;
|
|
font-size: 9px;
|
|
font-weight: 600;
|
|
padding: 1px 5px;
|
|
border-radius: 3px;
|
|
margin-right: 4px;
|
|
letter-spacing: 0.03em;
|
|
}
|
|
|
|
.session-model-badge-preview {
|
|
color: #fff;
|
|
background: #d97706;
|
|
}
|
|
|
|
.session-model-badge-custom {
|
|
color: #fff;
|
|
background: $accent-primary;
|
|
}
|
|
|
|
.session-model-badge-disabled {
|
|
color: $text-muted;
|
|
background: transparent;
|
|
border: 1px solid $border-color;
|
|
padding: 0 5px;
|
|
}
|
|
|
|
.session-model-empty {
|
|
padding: 24px 0;
|
|
text-align: center;
|
|
font-size: 13px;
|
|
color: $text-muted;
|
|
}
|
|
|
|
.session-model-custom {
|
|
margin-top: 12px;
|
|
padding-top: 12px;
|
|
border-top: 1px solid $border-color;
|
|
}
|
|
|
|
.session-model-custom-row {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
.session-model-custom-provider {
|
|
width: 160px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.session-model-custom-input {
|
|
flex: 1;
|
|
}
|
|
|
|
.session-model-custom-hint {
|
|
margin-top: 6px;
|
|
font-size: 11px;
|
|
color: $text-muted;
|
|
}
|
|
|
|
.session-list {
|
|
width: $sidebar-width;
|
|
border-right: 1px solid $border-color;
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex-shrink: 0;
|
|
transition:
|
|
width $transition-normal,
|
|
opacity $transition-normal;
|
|
overflow: hidden;
|
|
|
|
&.collapsed {
|
|
width: 0;
|
|
border-right: none;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
@media (max-width: $breakpoint-mobile) {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
height: 100%;
|
|
z-index: 120;
|
|
background: $bg-card;
|
|
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
|
|
width: $sidebar-width;
|
|
|
|
&.collapsed {
|
|
transform: translateX(-100%);
|
|
opacity: 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
@media (max-width: $breakpoint-mobile) {
|
|
.session-close-btn {
|
|
display: flex;
|
|
}
|
|
|
|
.session-backdrop {
|
|
position: absolute;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.4);
|
|
z-index: 110;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
transition: opacity $transition-fast;
|
|
|
|
&.active {
|
|
opacity: 1;
|
|
pointer-events: auto;
|
|
}
|
|
}
|
|
}
|
|
|
|
.page-sidebar-top {
|
|
flex-shrink: 0;
|
|
padding: 12px;
|
|
border-bottom: 1px solid var(--border-light);
|
|
}
|
|
|
|
.page-sidebar-tabs {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.page-sidebar-tab {
|
|
width: 100%;
|
|
min-width: 0;
|
|
height: 34px;
|
|
border: none;
|
|
border-radius: $radius-sm;
|
|
background: transparent;
|
|
color: $text-secondary;
|
|
display: inline-flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
justify-content: flex-start;
|
|
gap: 8px;
|
|
padding: 7px 10px;
|
|
cursor: pointer;
|
|
transition:
|
|
background-color $transition-fast,
|
|
color $transition-fast;
|
|
|
|
svg {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
span {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
font-size: 13px;
|
|
line-height: 18px;
|
|
}
|
|
|
|
&:hover {
|
|
background: rgba(var(--accent-primary-rgb), 0.06);
|
|
color: $text-primary;
|
|
}
|
|
}
|
|
|
|
.session-list-toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.session-list-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
height: 22px;
|
|
|
|
.arco-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 22px;
|
|
min-height: 22px;
|
|
}
|
|
}
|
|
|
|
.session-close-btn {
|
|
display: none;
|
|
border: none;
|
|
background: none;
|
|
cursor: pointer;
|
|
color: $text-secondary;
|
|
padding: 4px;
|
|
border-radius: $radius-sm;
|
|
height: 22px;
|
|
min-height: 22px;
|
|
align-items: center;
|
|
justify-content: center;
|
|
|
|
&:hover {
|
|
background: rgba($accent-primary, 0.06);
|
|
}
|
|
}
|
|
|
|
.session-list-title {
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: $text-muted;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
line-height: 22px;
|
|
}
|
|
|
|
.session-profile-filter {
|
|
min-width: 0;
|
|
flex: 1;
|
|
}
|
|
|
|
.conversation-switch {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 2px;
|
|
margin-top: 8px;
|
|
padding: 2px;
|
|
border-radius: $radius-sm;
|
|
background: rgba(var(--accent-primary-rgb), 0.05);
|
|
}
|
|
|
|
.conversation-switch-tab {
|
|
min-width: 0;
|
|
height: 28px;
|
|
border: none;
|
|
border-radius: 5px;
|
|
background: transparent;
|
|
color: $text-secondary;
|
|
font-size: 12px;
|
|
line-height: 16px;
|
|
cursor: pointer;
|
|
transition:
|
|
background-color $transition-fast,
|
|
color $transition-fast;
|
|
|
|
&:hover {
|
|
color: $text-primary;
|
|
}
|
|
|
|
&.active {
|
|
background: $bg-card;
|
|
color: $text-primary;
|
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
|
}
|
|
}
|
|
|
|
.new-chat-form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 14px;
|
|
}
|
|
|
|
:deep(.new-chat-drawer .arco-drawer) {
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
:deep(.new-chat-drawer .arco-drawer-header),
|
|
:deep(.new-chat-drawer .arco-drawer-footer) {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
:deep(.new-chat-drawer .arco-drawer-body) {
|
|
flex: 1 1 auto;
|
|
min-height: 0;
|
|
height: 100%;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
:deep(.new-chat-drawer .folder-picker) {
|
|
max-height: 260px;
|
|
}
|
|
|
|
:deep(.new-chat-drawer .folder-tree) {
|
|
max-height: 170px;
|
|
}
|
|
|
|
@media (max-width: $breakpoint-mobile) {
|
|
:deep(.new-chat-drawer .arco-drawer-body) {
|
|
padding-top: 12px;
|
|
padding-bottom: 12px;
|
|
}
|
|
|
|
:deep(.new-chat-drawer .folder-picker) {
|
|
max-height: 210px;
|
|
}
|
|
|
|
:deep(.new-chat-drawer .folder-tree) {
|
|
max-height: 128px;
|
|
}
|
|
}
|
|
|
|
.new-chat-field {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
|
|
.new-chat-label {
|
|
font-size: 12px;
|
|
color: $text-muted;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.new-chat-actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 8px;
|
|
}
|
|
|
|
.session-group-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 6px 10px 4px;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
}
|
|
|
|
.session-group-header--static {
|
|
cursor: default;
|
|
}
|
|
|
|
.group-chevron {
|
|
flex-shrink: 0;
|
|
transition: transform 0.15s ease;
|
|
transform: rotate(90deg);
|
|
|
|
&.collapsed {
|
|
transform: rotate(0deg);
|
|
}
|
|
}
|
|
|
|
.session-group-label {
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
color: $text-muted;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.session-group-count {
|
|
font-size: 10px;
|
|
color: $text-muted;
|
|
font-weight: 400;
|
|
}
|
|
|
|
.session-items {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 10px 6px 12px;
|
|
}
|
|
|
|
.page-sidebar-bottom {
|
|
flex-shrink: 0;
|
|
padding: 10px 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.page-sidebar-menu-btn {
|
|
flex: 1 1 auto;
|
|
width: auto;
|
|
min-width: 0;
|
|
height: 36px;
|
|
border: none;
|
|
border-radius: $radius-sm;
|
|
background: transparent;
|
|
color: $text-secondary;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: flex-start;
|
|
gap: 8px;
|
|
padding: 8px 10px;
|
|
cursor: pointer;
|
|
transition:
|
|
background-color $transition-fast,
|
|
color $transition-fast;
|
|
|
|
&:hover {
|
|
background: rgba(var(--accent-primary-rgb), 0.06);
|
|
color: $text-primary;
|
|
}
|
|
}
|
|
|
|
.page-sidebar-menu-btn span {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
font-size: 13px;
|
|
line-height: 18px;
|
|
}
|
|
|
|
.session-loading,
|
|
.session-empty {
|
|
padding: 16px 10px;
|
|
font-size: 12px;
|
|
color: $text-muted;
|
|
text-align: center;
|
|
}
|
|
|
|
.chat-main {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
min-width: 0;
|
|
}
|
|
|
|
.chat-content-wrapper {
|
|
flex: 1;
|
|
display: flex;
|
|
overflow: hidden;
|
|
position: relative;
|
|
min-width: 0;
|
|
max-width: 100%;
|
|
}
|
|
|
|
.chat-content-wrapper--drop-active::after {
|
|
content: "";
|
|
position: absolute;
|
|
inset: 12px;
|
|
z-index: 30;
|
|
pointer-events: none;
|
|
border: 2px dashed var(--accent-info);
|
|
border-radius: 8px;
|
|
background: rgba(var(--accent-info-rgb), 0.05);
|
|
}
|
|
|
|
.chat-main-content {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-width: 0;
|
|
background-color: $bg-card;
|
|
animation: chat-surface-fade-in 1.5s ease both;
|
|
}
|
|
|
|
@keyframes chat-surface-fade-in {
|
|
from {
|
|
opacity: 0;
|
|
}
|
|
|
|
to {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
.chat-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 21px 20px;
|
|
border-bottom: 1px solid $border-color;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.header-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
overflow: hidden;
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.header-session-title {
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
color: $text-primary;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.source-badge {
|
|
font-size: 10px;
|
|
color: $text-muted;
|
|
background: rgba($text-muted, 0.12);
|
|
padding: 1px 7px;
|
|
border-radius: 8px;
|
|
flex-shrink: 0;
|
|
white-space: nowrap;
|
|
line-height: 16px;
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.chat-mode-toggle {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
margin-right: 4px;
|
|
}
|
|
|
|
@media (max-width: $breakpoint-mobile) {
|
|
.chat-header {
|
|
padding: calc(16px + env(safe-area-inset-top, 0px)) 12px 16px 52px;
|
|
}
|
|
|
|
.header-sidebar-toggle {
|
|
display: none;
|
|
}
|
|
|
|
.header-session-title {
|
|
display: none;
|
|
}
|
|
|
|
}
|
|
|
|
.workspace-badge {
|
|
border: 0;
|
|
font-size: 11px;
|
|
line-height: 16px;
|
|
color: $text-muted;
|
|
background: rgba(255, 255, 255, 0.05);
|
|
padding: 2px 8px;
|
|
border-radius: 4px;
|
|
max-width: 160px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
overflow: hidden;
|
|
cursor: pointer;
|
|
|
|
svg {
|
|
flex: 0 0 auto;
|
|
}
|
|
|
|
span {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
&:hover {
|
|
color: $text-secondary;
|
|
background: rgba(var(--accent-primary-rgb), 0.06);
|
|
}
|
|
}
|
|
|
|
.header-tool-toggle.active {
|
|
color: var(--accent-primary);
|
|
background: rgba(var(--accent-primary-rgb), 0.1);
|
|
}
|
|
|
|
.chat-tool-panel {
|
|
position: relative;
|
|
flex: 0 0 auto;
|
|
min-width: 320px;
|
|
max-width: 100%;
|
|
background: $bg-card;
|
|
border-left: 1px solid $border-color;
|
|
display: flex;
|
|
min-height: 0;
|
|
overflow: visible;
|
|
}
|
|
|
|
.chat-tool-resize-handle {
|
|
position: absolute;
|
|
left: -7px;
|
|
top: 0;
|
|
bottom: 0;
|
|
width: 14px;
|
|
cursor: col-resize;
|
|
z-index: 20;
|
|
|
|
&::after {
|
|
content: "";
|
|
position: absolute;
|
|
left: 6px;
|
|
top: 0;
|
|
bottom: 0;
|
|
width: 1px;
|
|
background:
|
|
linear-gradient($border-color, $border-color) top / 1px calc(50% - 26px) no-repeat,
|
|
linear-gradient($border-color, $border-color) bottom / 1px calc(50% - 26px) no-repeat;
|
|
transition: background $transition-fast;
|
|
z-index: 1;
|
|
}
|
|
|
|
&::before {
|
|
content: "";
|
|
position: absolute;
|
|
left: 1px;
|
|
top: 50%;
|
|
width: 12px;
|
|
height: 38px;
|
|
transform: translateY(-50%);
|
|
border-radius: 6px;
|
|
background:
|
|
linear-gradient($text-muted, $text-muted) center 12px / 6px 1px no-repeat,
|
|
linear-gradient($text-muted, $text-muted) center 19px / 6px 1px no-repeat,
|
|
linear-gradient($text-muted, $text-muted) center 26px / 6px 1px no-repeat,
|
|
$bg-card;
|
|
border: 1px solid $border-color;
|
|
opacity: 0.9;
|
|
transition: all $transition-fast;
|
|
z-index: 2;
|
|
}
|
|
|
|
&:hover::after {
|
|
background:
|
|
linear-gradient(var(--accent-primary), var(--accent-primary)) top / 1px calc(50% - 26px) no-repeat,
|
|
linear-gradient(var(--accent-primary), var(--accent-primary)) bottom / 1px calc(50% - 26px) no-repeat;
|
|
}
|
|
|
|
&:hover::before {
|
|
background:
|
|
linear-gradient(var(--accent-primary), var(--accent-primary)) center 12px / 6px 1px no-repeat,
|
|
linear-gradient(var(--accent-primary), var(--accent-primary)) center 19px / 6px 1px no-repeat,
|
|
linear-gradient(var(--accent-primary), var(--accent-primary)) center 26px / 6px 1px no-repeat,
|
|
$bg-card;
|
|
border-color: var(--accent-primary);
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
.chat-tool-panel-inner {
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex: 1;
|
|
min-width: 0;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.chat-tool-tabs {
|
|
display: flex;
|
|
align-items: center;
|
|
flex-shrink: 0;
|
|
gap: 6px;
|
|
padding: 8px 10px;
|
|
border-bottom: 1px solid $border-color;
|
|
}
|
|
|
|
.chat-tool-tab {
|
|
height: 30px;
|
|
padding: 0 12px;
|
|
border: none;
|
|
border-radius: $radius-sm;
|
|
background: transparent;
|
|
color: $text-secondary;
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
transition: all $transition-fast;
|
|
|
|
&:hover {
|
|
color: $text-primary;
|
|
background: rgba(var(--accent-primary-rgb), 0.06);
|
|
}
|
|
|
|
&.active {
|
|
color: var(--accent-primary);
|
|
background: rgba(var(--accent-primary-rgb), 0.12);
|
|
}
|
|
}
|
|
|
|
.chat-tool-content {
|
|
flex: 1;
|
|
min-width: 0;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.chat-tool-content > * {
|
|
height: 100%;
|
|
min-height: 0;
|
|
}
|
|
|
|
@media (max-width: $breakpoint-mobile) {
|
|
.chat-tool-panel {
|
|
position: absolute;
|
|
top: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
z-index: 70;
|
|
left: 0;
|
|
width: 100% !important;
|
|
min-width: 0;
|
|
border-left: none;
|
|
box-shadow: none;
|
|
}
|
|
|
|
.chat-tool-resize-handle {
|
|
display: none;
|
|
}
|
|
}
|
|
|
|
/* ── Default Workspace Feature ─────────────────────────────────── */
|
|
|
|
.default-workspace-chips {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.default-workspace-label {
|
|
font-size: 13px;
|
|
color: var(--text-muted);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.workspace-chips-container {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
flex-wrap: nowrap;
|
|
position: relative;
|
|
}
|
|
|
|
.workspace-chip {
|
|
padding: 4px 12px;
|
|
font-size: 13px;
|
|
color: var(--text-secondary);
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
white-space: nowrap;
|
|
max-width: 150px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.workspace-chip:hover {
|
|
background: rgba(var(--accent-primary-rgb), 0.06);
|
|
border-color: var(--accent-muted);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.workspace-chip.active {
|
|
background: rgba(var(--accent-primary-rgb), 0.12);
|
|
border-color: var(--accent-muted);
|
|
color: var(--accent-primary);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.workspace-chip-separator {
|
|
color: var(--text-muted);
|
|
font-size: 13px;
|
|
user-select: none;
|
|
}
|
|
|
|
.workspace-chip-dropdown {
|
|
position: relative;
|
|
display: inline-block;
|
|
}
|
|
|
|
.workspace-chip-more {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 4px 12px;
|
|
font-size: 13px;
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.workspace-chip-more:hover {
|
|
background: rgba(var(--accent-primary-rgb), 0.06);
|
|
border-color: var(--accent-muted);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.workspace-dropdown-menu {
|
|
position: absolute;
|
|
top: 100%;
|
|
left: 0;
|
|
margin-top: 4px;
|
|
min-width: 200px;
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 6px;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
z-index: 9999;
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.workspace-dropdown-item {
|
|
padding: 8px 12px;
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
transition: background 0.15s;
|
|
color: var(--text-secondary);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.workspace-dropdown-item:hover {
|
|
background: var(--bg-card-hover);
|
|
}
|
|
|
|
.workspace-default-badge {
|
|
display: inline-block;
|
|
margin-left: 6px;
|
|
padding: 1px 6px;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
color: #f5a623;
|
|
background: rgba(245, 166, 35, 0.12);
|
|
border: 1px solid rgba(245, 166, 35, 0.3);
|
|
border-radius: 3px;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
.recent-workspaces {
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.recent-workspaces-label {
|
|
display: block;
|
|
font-size: 11px;
|
|
color: $text-muted;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.recent-workspaces-chips {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 4px;
|
|
}
|
|
|
|
.recent-pin-icon {
|
|
font-size: 12px;
|
|
line-height: 1;
|
|
cursor: pointer;
|
|
color: $text-muted;
|
|
transition: color $transition-fast;
|
|
|
|
&:hover {
|
|
color: #f5a623;
|
|
}
|
|
}
|
|
</style>
|