Release v0.5.8 (#422)
* fix: add missing i18n key and unify session data source (#408) - Add `chat.sessionNotFound` translation key to all 8 locales - Fix history page data source inconsistency: - Change `getHermesSession` to prioritize database over CLI - Now consistent with `listHermesSessions` behavior - Prevents "session in list but detail not found" issue - Update CI workflow to trigger on base branch PRs - Remove debug log from sessions-db Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: filter special characters and emoji in speech playback (#409) - Update extractReadableText to filter special characters like *# - Only keep common punctuation marks for speech synthesis - Remove emoji, symbols, and special unicode characters - Improve text-to-speech readability Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add drawer panel with mobile sidebar support and customizable button (#412) * feat: add drawer panel with mobile sidebar support - Add DrawerPanel component with Terminal and Files tabs - Extract TerminalPanel and FilesPanel from existing views - Add mobile sidebar toggle functionality with overlay - Add rainbow breathing light effect to drawer button - Remove Tools section from AppSidebar (Terminal/Files entries) - Add i18n support for drawer and file tree - Optimize mobile button layout and spacing - Fix z-index hierarchy for proper layering - Add responsive sidebar behavior (PC: always visible, Mobile: toggle) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: customize drawer button with arc rainbow border - Change drawer button to semi-circle shape贴着右边 - Add arrow icon pointing left (向左箭头) - Add rainbow border from top to bottom through semi-circle arc - Slow down animation from 4s to 8s for smoother effect - Move drawer button wrapper to messages area only (not贯穿header和input) - Add semi-transparent accent color background to button Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: resolve profile switching state sync issue (#414) (#415) * fix: resolve profile switching state sync issue (#414) Fix bug where switching to a different profile would still show the old profile name in the UI and prevent switching back to default. Root cause: - Frontend relied entirely on fetchProfiles() return value to set activeProfileName - Backend Hermes CLI may return stale active flag due to timing issues between profile use and profile list commands - This caused frontend to display wrong profile and prevented switching back to default Solution: - Immediately set activeProfileName when switchProfile API succeeds - Don't rely solely on listProfiles() result which may have stale data - Use activeProfileName instead of activeProfile?.name in ProfileSelector Changes: - profiles store: Set activeProfileName immediately after successful switch - ProfileSelector: Use activeProfileName computed property - Add test to verify activeProfileName updates on switch Fixes #414 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refine: improve error handling for profile switching failures Add proper error handling for edge cases: - If fetchProfiles() fails after successful switch, keep the updated activeProfileName (don't let fetchProfiles failure undo the switch) - Add test cases to verify: 1. API failure doesn't change state 2. fetchProfiles failure doesn't affect successful switch This ensures the UI remains consistent even when profile list refresh fails after a successful profile switch. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refine: add rollback mechanism for profile switching verification Add backend verification after profile switch: - Save old activeProfileName before setting new value - After fetchProfiles, verify backend reports expected active profile - If backend reports different profile, rollback frontend state and return false - This handles edge case where API returns 200 but backend didn't actually switch Test cases: - ✅ Normal switch: updates and verifies successfully - ✅ API failure: doesn't change state - ✅ fetchProfiles failure: assumes success (API returned 200) - ✅ Backend verification fails: rolls back to old profile This ensures frontend state always matches backend reality, even in edge cases where hermes profile use succeeded but gateway/cleanup steps failed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refine: add user feedback for profile operations Improve user experience with success/error messages: - ProfileSelector: Add error message when switch fails - ProfileCard: Add success message before reload on switch - ProfileSelector: Use async/await for better error handling - ProfileCard: Add 500ms delay before reload to show success message Before: Silent failures, no feedback After: Clear success/error messages for all operations Example feedback: - Success: "已切换到配置 qinghe" - Failure: "切换配置失败,网关可能需要手动重启" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: update frontend changelog for v0.5.7 (#419) * docs: update frontend changelog for v0.5.7 - Update changelog.ts with v0.5.7 release date and changes - Add i18n translation keys for all languages (en, zh, de, es, fr, ja, ko, pt) - Include v0.5.7 changelog entries: - Optimize context compression and session sync - Add startup delays to prevent database race conditions Changes: - packages/client/src/data/changelog.ts: Update v0.5.7 entry - packages/client/src/i18n/locales/*.ts: Add changelog translation section This enables the changelog modal in the UI to display v0.5.7 release notes. * feat: add v0.5.7 changelog translations to all supported languages Add new_0_5_7_1, new_0_5_7_2, and new_0_5_7_3 changelog entries to all locale files (en, zh, de, es, fr, ja, ko, pt) with proper translations for each language. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: remove duplicate changelog sections causing syntax errors Remove duplicate changelog object sections that were causing TypeScript syntax errors in all locale files (en, zh, de, es, fr, ja, ko, pt). The actual changelog entries are already correctly placed in the main changelog section of each file. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add v0.5.8 changelog and fix profile parsing issue Add v0.5.8 changelog entries based on PRs merged since v0.5.7: - Drawer panel with mobile sidebar support (#412) - Profile switching state sync fix (#414) - Speech playback special character filtering (#409) - Missing i18n key and session data source unification (#408) - Vite build optimization for faster Docker builds (#403) Also fix issue #417: Profile names with long hyphenated names fail to parse in profile list regex. Change \s{2,} to \s+ to handle compressed column spacing when profile names are long. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: remove enter key submit from profile creation and rename modals Remove @keyup.enter handlers from NInput components in: - ProfileCreateModal: prevent accidental profile creation when pressing enter - ProfileRenameModal: prevent accidental profile rename when pressing enter Users must now explicitly click the confirm button to submit, preventing unintended profile operations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: allow free text input for profile names Remove frontend character filtering from profile creation and rename modals. Users can now input any characters including spaces and uppercase letters to test backend Hermes CLI validation. Changes: - ProfileCreateModal: Remove toLowerCase() and character filtering - ProfileRenameModal: Remove toLowerCase() and character filtering - Use v-model:value binding instead of :value with @input Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: improve error handling for profile creation Display backend error messages when profile creation fails instead of generic "failed" message. This helps users understand why their profile name was rejected (e.g., invalid characters). Changes: - API layer: Capture and return error messages from backend - ProfileCreateModal: Display specific error message from backend Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add profile name validation with i18n support Add client-side validation for profile names to prevent invalid input before sending to backend. Only lowercase letters, numbers, underscores, and hyphens are allowed. Changes: - ProfileCreateModal: Add input validation with real-time feedback - ProfileRenameModal: Add input validation with real-time feedback - Add nameValidation i18n key for all 8 languages - Filter invalid characters on input and show warning message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: revert profile parsing regex changes Revert the regex changes in hermes-cli.ts and gateway-manager.ts back to requiring \s{2,} (at least 2 spaces). Since frontend now validates profile names to only allow lowercase letters, numbers, underscores, and hyphens, the relaxed regex is no longer needed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: revert profile parsing regex changes Revert the regex changes in gateway-manager.ts and hermes-cli.ts back to requiring \s{2,} (at least 2 spaces). Since frontend now validates profile names to only allow lowercase letters, numbers, underscores, and hyphens, the relaxed regex is no longer needed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: remove tooltip from drawer button Remove the NTooltip wrapper from the floating drawer button. The "Terminal & Files" tooltip is no longer shown on hover. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * Update assets images (#421) Updated two asset images in the client package. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,24 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { renameSession, setSessionWorkspace } from '@/api/hermes/sessions'
|
||||
import { useChatStore, type Session } from '@/stores/hermes/chat'
|
||||
import { useSessionBrowserPrefsStore } from '@/stores/hermes/session-browser-prefs'
|
||||
import { NButton, NDropdown, NInput, NModal, NTooltip, useMessage } from 'naive-ui'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { getSourceLabel } from '@/shared/session-display'
|
||||
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 { renameSession, setSessionWorkspace } from "@/api/hermes/sessions";
|
||||
import { useChatStore, type Session } from "@/stores/hermes/chat";
|
||||
import { useSessionBrowserPrefsStore } from "@/stores/hermes/session-browser-prefs";
|
||||
import {
|
||||
NButton,
|
||||
NDropdown,
|
||||
NInput,
|
||||
NModal,
|
||||
NTooltip,
|
||||
useMessage,
|
||||
} from "naive-ui";
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { getSourceLabel } from "@/shared/session-display";
|
||||
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 DrawerPanel from "./DrawerPanel.vue";
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const sessionBrowserPrefsStore = useSessionBrowserPrefsStore()
|
||||
const message = useMessage()
|
||||
const { t } = useI18n()
|
||||
const chatStore = useChatStore();
|
||||
const sessionBrowserPrefsStore = useSessionBrowserPrefsStore();
|
||||
const message = useMessage();
|
||||
const { t } = useI18n();
|
||||
|
||||
const currentMode = ref<'chat' | 'live'>('chat')
|
||||
const showDrawer = ref(false);
|
||||
const drawerActiveTab = ref<"terminal" | "files">("files");
|
||||
|
||||
const currentMode = ref<"chat" | "live">("chat");
|
||||
|
||||
// Initialize synchronously from the media query so first paint is correct.
|
||||
// On narrow viewports the session list is an absolute-positioned overlay
|
||||
@@ -27,275 +38,363 @@ const currentMode = ref<'chat' | 'live'>('chat')
|
||||
// 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)
|
||||
typeof window === "undefined" ||
|
||||
!window.matchMedia("(max-width: 768px)").matches,
|
||||
);
|
||||
let mobileQuery: MediaQueryList | null = null;
|
||||
const isMobile = ref(false);
|
||||
|
||||
function handleSessionClick(sessionId: string) {
|
||||
chatStore.switchSession(sessionId)
|
||||
if (mobileQuery?.matches) showSessions.value = false
|
||||
chatStore.switchSession(sessionId);
|
||||
if (mobileQuery?.matches) showSessions.value = false;
|
||||
}
|
||||
|
||||
function handleMobileChange(e: MediaQueryListEvent | MediaQueryList) {
|
||||
isMobile.value = e.matches
|
||||
isMobile.value = e.matches;
|
||||
if (e.matches && showSessions.value) {
|
||||
showSessions.value = false
|
||||
showSessions.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
mobileQuery = window.matchMedia('(max-width: 768px)')
|
||||
handleMobileChange(mobileQuery)
|
||||
mobileQuery.addEventListener('change', handleMobileChange)
|
||||
})
|
||||
mobileQuery = window.matchMedia("(max-width: 768px)");
|
||||
handleMobileChange(mobileQuery);
|
||||
mobileQuery.addEventListener("change", handleMobileChange);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
mobileQuery?.removeEventListener('change', handleMobileChange)
|
||||
})
|
||||
const showRenameModal = ref(false)
|
||||
const renameValue = ref('')
|
||||
const renameSessionId = ref<string | null>(null)
|
||||
const renameInputRef = ref<InstanceType<typeof NInput> | null>(null)
|
||||
const collapsedGroups = ref<Set<string>>(new Set(JSON.parse(localStorage.getItem('hermes_collapsed_groups') || '[]')))
|
||||
mobileQuery?.removeEventListener("change", handleMobileChange);
|
||||
});
|
||||
const showRenameModal = ref(false);
|
||||
const renameValue = ref("");
|
||||
const renameSessionId = ref<string | null>(null);
|
||||
const renameInputRef = ref<InstanceType<typeof NInput> | null>(null);
|
||||
const collapsedGroups = ref<Set<string>>(
|
||||
new Set(JSON.parse(localStorage.getItem("hermes_collapsed_groups") || "[]")),
|
||||
);
|
||||
|
||||
// Source sort order: api_server first, cron last, others alphabetical
|
||||
function sourceSortKey(source: string): number {
|
||||
if (source === 'api_server') return -1
|
||||
if (source === 'cron') return 999
|
||||
return 0
|
||||
if (source === "api_server") return -1;
|
||||
if (source === "cron") return 999;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function sortSessionsWithActiveFirst(items: Session[]): Session[] {
|
||||
return [...items].sort((a, b) => {
|
||||
return (b.updatedAt || 0) - (a.updatedAt || 0)
|
||||
})
|
||||
return (b.updatedAt || 0) - (a.updatedAt || 0);
|
||||
});
|
||||
}
|
||||
|
||||
// Group sessions by source, with sort order
|
||||
interface SessionGroup {
|
||||
source: string
|
||||
label: string
|
||||
sessions: Session[]
|
||||
source: string;
|
||||
label: string;
|
||||
sessions: Session[];
|
||||
}
|
||||
|
||||
const pinnedSessions = computed(() =>
|
||||
sortSessionsWithActiveFirst(chatStore.sessions.filter(session => sessionBrowserPrefsStore.isPinned(session.id))),
|
||||
)
|
||||
sortSessionsWithActiveFirst(
|
||||
chatStore.sessions.filter((session) =>
|
||||
sessionBrowserPrefsStore.isPinned(session.id),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const groupedSessions = computed<SessionGroup[]>(() => {
|
||||
const map = new Map<string, Session[]>()
|
||||
const map = new Map<string, Session[]>();
|
||||
for (const s of chatStore.sessions) {
|
||||
if (sessionBrowserPrefsStore.isPinned(s.id)) continue
|
||||
const key = s.source || ''
|
||||
if (!map.has(key)) map.set(key, [])
|
||||
map.get(key)!.push(s)
|
||||
if (sessionBrowserPrefsStore.isPinned(s.id)) continue;
|
||||
const key = s.source || "";
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(s);
|
||||
}
|
||||
|
||||
const keys = [...map.keys()].sort((a, b) => {
|
||||
const ka = sourceSortKey(a)
|
||||
const kb = sourceSortKey(b)
|
||||
if (ka !== kb) return ka - kb
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
const ka = sourceSortKey(a);
|
||||
const kb = sourceSortKey(b);
|
||||
if (ka !== kb) return ka - kb;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
return keys.map(key => ({
|
||||
return keys.map((key) => ({
|
||||
source: key,
|
||||
label: key ? getSourceLabel(key) : t('chat.other'),
|
||||
label: key ? getSourceLabel(key) : t("chat.other"),
|
||||
sessions: sortSessionsWithActiveFirst(map.get(key)!),
|
||||
}))
|
||||
})
|
||||
}));
|
||||
});
|
||||
|
||||
function toggleGroup(source: string) {
|
||||
const isExpanded = !collapsedGroups.value.has(source)
|
||||
const isExpanded = !collapsedGroups.value.has(source);
|
||||
if (isExpanded) {
|
||||
collapsedGroups.value = new Set([...collapsedGroups.value, source])
|
||||
collapsedGroups.value = new Set([...collapsedGroups.value, source]);
|
||||
} else {
|
||||
collapsedGroups.value = new Set(
|
||||
groupedSessions.value.map(g => g.source).filter(s => s !== source),
|
||||
)
|
||||
const group = groupedSessions.value.find(g => g.source === source)
|
||||
groupedSessions.value.map((g) => g.source).filter((s) => s !== source),
|
||||
);
|
||||
const group = groupedSessions.value.find((g) => g.source === source);
|
||||
if (group?.sessions.length) {
|
||||
chatStore.switchSession(group.sessions[0].id)
|
||||
chatStore.switchSession(group.sessions[0].id);
|
||||
}
|
||||
}
|
||||
localStorage.setItem('hermes_collapsed_groups', JSON.stringify([...collapsedGroups.value]))
|
||||
localStorage.setItem(
|
||||
"hermes_collapsed_groups",
|
||||
JSON.stringify([...collapsedGroups.value]),
|
||||
);
|
||||
}
|
||||
|
||||
watch(groupedSessions, groups => {
|
||||
if (localStorage.getItem('hermes_collapsed_groups') !== null) {
|
||||
const activeSource = chatStore.activeSession?.source
|
||||
if (activeSource && collapsedGroups.value.has(activeSource)) {
|
||||
collapsedGroups.value = new Set([...collapsedGroups.value].filter(source => source !== activeSource))
|
||||
localStorage.setItem('hermes_collapsed_groups', JSON.stringify([...collapsedGroups.value]))
|
||||
watch(
|
||||
groupedSessions,
|
||||
(groups) => {
|
||||
if (localStorage.getItem("hermes_collapsed_groups") !== null) {
|
||||
const activeSource = chatStore.activeSession?.source;
|
||||
if (activeSource && collapsedGroups.value.has(activeSource)) {
|
||||
collapsedGroups.value = new Set(
|
||||
[...collapsedGroups.value].filter(
|
||||
(source) => source !== activeSource,
|
||||
),
|
||||
);
|
||||
localStorage.setItem(
|
||||
"hermes_collapsed_groups",
|
||||
JSON.stringify([...collapsedGroups.value]),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
return
|
||||
}
|
||||
collapsedGroups.value = new Set(groups.slice(1).map(group => group.source))
|
||||
localStorage.setItem('hermes_collapsed_groups', JSON.stringify([...collapsedGroups.value]))
|
||||
}, { once: true })
|
||||
collapsedGroups.value = new Set(
|
||||
groups.slice(1).map((group) => group.source),
|
||||
);
|
||||
localStorage.setItem(
|
||||
"hermes_collapsed_groups",
|
||||
JSON.stringify([...collapsedGroups.value]),
|
||||
);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
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)
|
||||
() => [
|
||||
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 activeSessionTitle = computed(
|
||||
() => chatStore.activeSession?.title || t("chat.newChat"),
|
||||
);
|
||||
|
||||
const headerTitle = computed(() =>
|
||||
currentMode.value === 'live' ? t('chat.liveSessions') : activeSessionTitle.value,
|
||||
)
|
||||
currentMode.value === "live"
|
||||
? t("chat.liveSessions")
|
||||
: activeSessionTitle.value,
|
||||
);
|
||||
|
||||
const activeSessionSource = computed(() =>
|
||||
currentMode.value === 'chat' ? (chatStore.activeSession?.source || '') : '',
|
||||
)
|
||||
currentMode.value === "chat" ? chatStore.activeSession?.source || "" : "",
|
||||
);
|
||||
|
||||
function handleNewChat() {
|
||||
chatStore.newChat()
|
||||
chatStore.newChat();
|
||||
}
|
||||
|
||||
async function copySessionId(id?: string) {
|
||||
const sessionId = id || chatStore.activeSessionId
|
||||
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') + ' ✗')
|
||||
const ok = await copyToClipboard(sessionId);
|
||||
if (ok) message.success(t("common.copied"));
|
||||
else message.error(t("common.copied") + " ✗");
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteSession(id: string) {
|
||||
sessionBrowserPrefsStore.removePinned(id)
|
||||
chatStore.deleteSession(id)
|
||||
message.success(t('chat.sessionDeleted'))
|
||||
sessionBrowserPrefsStore.removePinned(id);
|
||||
chatStore.deleteSession(id);
|
||||
message.success(t("chat.sessionDeleted"));
|
||||
}
|
||||
|
||||
const contextSessionId = ref<string | null>(null)
|
||||
const contextSessionId = ref<string | null>(null);
|
||||
const contextSessionPinned = computed(() =>
|
||||
contextSessionId.value ? sessionBrowserPrefsStore.isPinned(contextSessionId.value) : false,
|
||||
)
|
||||
contextSessionId.value
|
||||
? sessionBrowserPrefsStore.isPinned(contextSessionId.value)
|
||||
: false,
|
||||
);
|
||||
|
||||
const contextMenuOptions = computed(() => [
|
||||
{ label: t(contextSessionPinned.value ? 'chat.unpin' : 'chat.pin'), key: 'pin' },
|
||||
{ label: t('chat.rename'), key: 'rename' },
|
||||
{ label: t('chat.setWorkspace'), key: 'workspace' },
|
||||
{ label: t('chat.copySessionId'), key: 'copy-id' },
|
||||
])
|
||||
{
|
||||
label: t(contextSessionPinned.value ? "chat.unpin" : "chat.pin"),
|
||||
key: "pin",
|
||||
},
|
||||
{ label: t("chat.rename"), key: "rename" },
|
||||
{ label: t("chat.setWorkspace"), key: "workspace" },
|
||||
{ label: t("chat.copySessionId"), key: "copy-id" },
|
||||
]);
|
||||
|
||||
function handleContextMenu(e: MouseEvent, sessionId: string) {
|
||||
e.preventDefault()
|
||||
contextSessionId.value = sessionId
|
||||
showContextMenu.value = true
|
||||
contextMenuX.value = e.clientX
|
||||
contextMenuY.value = e.clientY
|
||||
e.preventDefault();
|
||||
contextSessionId.value = sessionId;
|
||||
showContextMenu.value = true;
|
||||
contextMenuX.value = e.clientX;
|
||||
contextMenuY.value = e.clientY;
|
||||
}
|
||||
|
||||
const showContextMenu = ref(false)
|
||||
const contextMenuX = ref(0)
|
||||
const contextMenuY = ref(0)
|
||||
const showContextMenu = ref(false);
|
||||
const contextMenuX = ref(0);
|
||||
const contextMenuY = ref(0);
|
||||
|
||||
function handleContextMenuSelect(key: string) {
|
||||
showContextMenu.value = false
|
||||
if (!contextSessionId.value) return
|
||||
if (key === 'pin') {
|
||||
sessionBrowserPrefsStore.togglePinned(contextSessionId.value)
|
||||
return
|
||||
showContextMenu.value = false;
|
||||
if (!contextSessionId.value) return;
|
||||
if (key === "pin") {
|
||||
sessionBrowserPrefsStore.togglePinned(contextSessionId.value);
|
||||
return;
|
||||
}
|
||||
if (key === 'copy-id') {
|
||||
copySessionId(contextSessionId.value)
|
||||
} 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 === 'rename') {
|
||||
const session = chatStore.sessions.find(s => s.id === contextSessionId.value)
|
||||
renameSessionId.value = contextSessionId.value
|
||||
renameValue.value = session?.title || ''
|
||||
showRenameModal.value = true
|
||||
if (key === "copy-id") {
|
||||
copySessionId(contextSessionId.value);
|
||||
} 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 === "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()
|
||||
})
|
||||
renameInputRef.value?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside() {
|
||||
showContextMenu.value = false
|
||||
showContextMenu.value = false;
|
||||
}
|
||||
|
||||
async function handleRenameConfirm() {
|
||||
if (!renameSessionId.value || !renameValue.value.trim()) return
|
||||
const ok = await renameSession(renameSessionId.value, renameValue.value.trim())
|
||||
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()
|
||||
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()
|
||||
chatStore.activeSession.title = renameValue.value.trim();
|
||||
}
|
||||
message.success(t('chat.renamed'))
|
||||
message.success(t("chat.renamed"));
|
||||
} else {
|
||||
message.error(t('chat.renameFailed'))
|
||||
message.error(t("chat.renameFailed"));
|
||||
}
|
||||
showRenameModal.value = false
|
||||
showRenameModal.value = false;
|
||||
}
|
||||
|
||||
const showWorkspaceModal = ref(false)
|
||||
const workspaceValue = ref('')
|
||||
const workspaceSessionId = ref<string | null>(null)
|
||||
const showWorkspaceModal = ref(false);
|
||||
const workspaceValue = ref("");
|
||||
const workspaceSessionId = ref<string | null>(null);
|
||||
|
||||
async function handleWorkspaceConfirm() {
|
||||
if (!workspaceSessionId.value) return
|
||||
const ok = await setSessionWorkspace(workspaceSessionId.value, workspaceValue.value || null)
|
||||
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
|
||||
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
|
||||
chatStore.activeSession.workspace = workspaceValue.value || null;
|
||||
}
|
||||
message.success(t('chat.workspaceSet'))
|
||||
message.success(t("chat.workspaceSet"));
|
||||
} else {
|
||||
message.error(t('chat.workspaceSetFailed'))
|
||||
message.error(t("chat.workspaceSetFailed"));
|
||||
}
|
||||
showWorkspaceModal.value = false
|
||||
showWorkspaceModal.value = false;
|
||||
}
|
||||
</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="currentMode === 'chat'"
|
||||
class="session-backdrop"
|
||||
:class="{ active: showSessions }"
|
||||
@click="showSessions = false"
|
||||
/>
|
||||
<aside
|
||||
v-if="currentMode === 'chat'"
|
||||
class="session-list"
|
||||
:class="{ collapsed: !showSessions }"
|
||||
>
|
||||
<div class="session-list-header">
|
||||
<span v-if="showSessions" class="session-list-title">{{ t('chat.webUiSessions') }}</span>
|
||||
<span v-if="showSessions" class="session-list-title">{{
|
||||
t("chat.webUiSessions")
|
||||
}}</span>
|
||||
<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>
|
||||
<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>
|
||||
<NButton quaternary size="tiny" @click="handleNewChat" circle>
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</template>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showSessions" class="session-scope-note">
|
||||
<span>{{ t('chat.sessionScopeHint') }}</span>
|
||||
<span>{{ t("chat.sessionScopeHint") }}</span>
|
||||
<RouterLink class="session-scope-link" :to="{ name: 'hermes.history' }">
|
||||
{{ t('chat.openHistory') }}
|
||||
{{ t("chat.openHistory") }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div v-if="showSessions" 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>
|
||||
<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-label">{{ t("chat.pinned") }}</span>
|
||||
<span class="session-group-count">{{ pinnedSessions.length }}</span>
|
||||
</div>
|
||||
<SessionListItem
|
||||
@@ -304,7 +403,10 @@ async function handleWorkspaceConfirm() {
|
||||
:session="s"
|
||||
:active="s.id === chatStore.activeSessionId"
|
||||
:pinned="true"
|
||||
:can-delete="s.id !== chatStore.activeSessionId || chatStore.sessions.length > 1"
|
||||
:can-delete="
|
||||
s.id !== chatStore.activeSessionId ||
|
||||
chatStore.sessions.length > 1
|
||||
"
|
||||
:streaming="chatStore.isSessionLive(s.id)"
|
||||
@select="handleSessionClick(s.id)"
|
||||
@contextmenu="handleContextMenu($event, s.id)"
|
||||
@@ -314,7 +416,18 @@ async function handleWorkspaceConfirm() {
|
||||
|
||||
<template v-for="group in groupedSessions" :key="group.source">
|
||||
<div class="session-group-header" @click="toggleGroup(group.source)">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="group-chevron" :class="{ collapsed: collapsedGroups.has(group.source) }"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
class="group-chevron"
|
||||
:class="{ collapsed: collapsedGroups.has(group.source) }"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
<span class="session-group-label">{{ group.label }}</span>
|
||||
<span class="session-group-count">{{ group.sessions.length }}</span>
|
||||
</div>
|
||||
@@ -325,7 +438,10 @@ async function handleWorkspaceConfirm() {
|
||||
:session="s"
|
||||
:active="s.id === chatStore.activeSessionId"
|
||||
:pinned="false"
|
||||
:can-delete="s.id !== chatStore.activeSessionId || chatStore.sessions.length > 1"
|
||||
:can-delete="
|
||||
s.id !== chatStore.activeSessionId ||
|
||||
chatStore.sessions.length > 1
|
||||
"
|
||||
:streaming="chatStore.isSessionLive(s.id)"
|
||||
@select="handleSessionClick(s.id)"
|
||||
@contextmenu="handleContextMenu($event, s.id)"
|
||||
@@ -378,33 +494,89 @@ async function handleWorkspaceConfirm() {
|
||||
<div class="chat-main">
|
||||
<header class="chat-header">
|
||||
<div class="header-left">
|
||||
<NButton v-if="currentMode === 'chat'" quaternary size="small" @click="showSessions = !showSessions" circle>
|
||||
<NButton
|
||||
v-if="currentMode === 'chat'"
|
||||
quaternary
|
||||
size="small"
|
||||
@click="showSessions = !showSessions"
|
||||
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>
|
||||
<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>
|
||||
</NButton>
|
||||
<span class="header-session-title">{{ headerTitle }}</span>
|
||||
<span v-if="activeSessionSource" class="source-badge">{{ getSourceLabel(activeSessionSource) }}</span>
|
||||
<span v-if="chatStore.activeSession?.workspace" class="workspace-badge" :title="chatStore.activeSession.workspace">📁 {{ chatStore.activeSession.workspace.split('/').pop() || chatStore.activeSession.workspace }}</span>
|
||||
<span v-if="activeSessionSource" class="source-badge">{{
|
||||
getSourceLabel(activeSessionSource)
|
||||
}}</span>
|
||||
<span
|
||||
v-if="chatStore.activeSession?.workspace"
|
||||
class="workspace-badge"
|
||||
:title="chatStore.activeSession.workspace"
|
||||
>📁
|
||||
{{
|
||||
chatStore.activeSession.workspace.split("/").pop() ||
|
||||
chatStore.activeSession.workspace
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<!-- chat/live mode toggle hidden -->
|
||||
<template v-if="currentMode === 'chat'">
|
||||
<NTooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<NButton quaternary size="small" @click="copySessionId()" circle>
|
||||
<NButton
|
||||
quaternary
|
||||
size="small"
|
||||
@click="copySessionId()"
|
||||
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>
|
||||
<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>
|
||||
</NButton>
|
||||
</template>
|
||||
{{ t('chat.copySessionId') }}
|
||||
{{ t("chat.copySessionId") }}
|
||||
</NTooltip>
|
||||
<NButton size="small" :circle="isMobile" @click="handleNewChat">
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</template>
|
||||
<template v-if="!isMobile">{{ t('chat.newChat') }}</template>
|
||||
<template v-if="!isMobile">{{ t("chat.newChat") }}</template>
|
||||
</NButton>
|
||||
</template>
|
||||
</div>
|
||||
@@ -414,13 +586,36 @@ async function handleWorkspaceConfirm() {
|
||||
<MessageList />
|
||||
<ChatInput />
|
||||
</template>
|
||||
<ConversationMonitorPane v-else :human-only="sessionBrowserPrefsStore.humanOnly" />
|
||||
<ConversationMonitorPane
|
||||
v-else
|
||||
:human-only="sessionBrowserPrefsStore.humanOnly"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Floating drawer button -->
|
||||
<div class="drawer-button-wrapper">
|
||||
<div class="drawer-button" @click="showDrawer = true">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DrawerPanel v-model:show="showDrawer" :active-tab="drawerActiveTab" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.chat-panel {
|
||||
display: flex;
|
||||
@@ -434,7 +629,9 @@ async function handleWorkspaceConfirm() {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
transition: width $transition-normal, opacity $transition-normal;
|
||||
transition:
|
||||
width $transition-normal,
|
||||
opacity $transition-normal;
|
||||
overflow: hidden;
|
||||
|
||||
&.collapsed {
|
||||
@@ -659,8 +856,12 @@ async function handleWorkspaceConfirm() {
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.session-item-pin) {
|
||||
@@ -792,4 +993,125 @@ async function handleWorkspaceConfirm() {
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
// ─── Drawer button ─────────────────────────────────────────────
|
||||
|
||||
.drawer-button-wrapper {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 100;
|
||||
background: $bg-card;
|
||||
border-radius: 50%;
|
||||
box-shadow:
|
||||
0 0 10px rgba(255, 107, 107, 0.4),
|
||||
0 0 20px rgba(255, 107, 107, 0.2);
|
||||
animation: rainbow-glow 8s linear infinite;
|
||||
transition: all $transition-fast;
|
||||
|
||||
&:hover {
|
||||
animation-play-state: paused;
|
||||
box-shadow:
|
||||
0 0 15px rgba(255, 107, 107, 0.6),
|
||||
0 0 30px rgba(255, 107, 107, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: rgba(var(--accent-primary-rgb), 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all $transition-fast;
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rainbow-glow {
|
||||
0% {
|
||||
box-shadow:
|
||||
0 0 0 2px #ff6b6b,
|
||||
0 0 10px rgba(255, 107, 107, 0.4),
|
||||
0 0 20px rgba(255, 107, 107, 0.2);
|
||||
border-color: #ff6b6b;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
16.66% {
|
||||
box-shadow:
|
||||
0 0 0 2px #feca57,
|
||||
0 0 10px rgba(254, 202, 87, 0.4),
|
||||
0 0 20px rgba(254, 202, 87, 0.2);
|
||||
border-color: #feca57;
|
||||
color: #feca57;
|
||||
}
|
||||
33.33% {
|
||||
box-shadow:
|
||||
0 0 0 2px #48dbfb,
|
||||
0 0 10px rgba(72, 219, 251, 0.4),
|
||||
0 0 20px rgba(72, 219, 251, 0.2);
|
||||
border-color: #48dbfb;
|
||||
color: #48dbfb;
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 0 2px #ff9ff3,
|
||||
0 0 10px rgba(255, 159, 243, 0.4),
|
||||
0 0 20px rgba(255, 159, 243, 0.2);
|
||||
border-color: #ff9ff3;
|
||||
color: #ff9ff3;
|
||||
}
|
||||
66.66% {
|
||||
box-shadow:
|
||||
0 0 0 2px #54a0ff,
|
||||
0 0 10px rgba(84, 160, 255, 0.4),
|
||||
0 0 20px rgba(84, 160, 255, 0.2);
|
||||
border-color: #54a0ff;
|
||||
color: #54a0ff;
|
||||
}
|
||||
83.33% {
|
||||
box-shadow:
|
||||
0 0 0 2px #5f27cd,
|
||||
0 0 10px rgba(95, 39, 205, 0.4),
|
||||
0 0 20px rgba(95, 39, 205, 0.2);
|
||||
border-color: #5f27cd;
|
||||
color: #5f27cd;
|
||||
}
|
||||
100% {
|
||||
box-shadow:
|
||||
0 0 0 2px #ff6b6b,
|
||||
0 0 10px rgba(255, 107, 107, 0.4),
|
||||
0 0 20px rgba(255, 107, 107, 0.2);
|
||||
border-color: #ff6b6b;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
.drawer-button-wrapper {
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
.drawer-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import TerminalPanel from './TerminalPanel.vue'
|
||||
import FilesPanel from './FilesPanel.vue'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
activeTab?: 'terminal' | 'files'
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:show', value: boolean): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
activeTab: 'files'
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const activeTab = ref<'terminal' | 'files'>(props.activeTab)
|
||||
|
||||
watch(() => props.activeTab, (newVal) => {
|
||||
if (newVal) activeTab.value = newVal
|
||||
})
|
||||
|
||||
function handleClose() {
|
||||
emit('update:show', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="show" class="drawer-overlay" @click="handleClose"></div>
|
||||
<div :class="['drawer-panel', { show }]">
|
||||
<div class="drawer-header">
|
||||
<div class="drawer-tabs">
|
||||
<button
|
||||
:class="['tab-button', { active: activeTab === 'files' }]"
|
||||
@click="activeTab = 'files'"
|
||||
>
|
||||
{{ t('drawer.files') }}
|
||||
</button>
|
||||
<button
|
||||
:class="['tab-button', { active: activeTab === 'terminal' }]"
|
||||
@click="activeTab = 'terminal'"
|
||||
>
|
||||
{{ t('drawer.terminal') }}
|
||||
</button>
|
||||
</div>
|
||||
<button class="close-button" @click="handleClose">
|
||||
<svg width="20" height="20" 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>
|
||||
</div>
|
||||
|
||||
<div class="drawer-content">
|
||||
<div v-show="activeTab === 'files'" class="drawer-pane">
|
||||
<FilesPanel />
|
||||
</div>
|
||||
<div v-show="activeTab === 'terminal'" class="drawer-pane">
|
||||
<TerminalPanel />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.drawer-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.drawer-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: -900px;
|
||||
width: 900px;
|
||||
height: 100vh;
|
||||
background: $bg-card;
|
||||
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 1000;
|
||||
transition: right 0.3s ease;
|
||||
|
||||
&.show {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
width: 100%;
|
||||
right: -100%;
|
||||
|
||||
&.show {
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.drawer-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: $text-secondary;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
border-radius: $radius-sm;
|
||||
|
||||
&:hover {
|
||||
color: $text-primary;
|
||||
background: rgba(var(--accent-primary-rgb), 0.05);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--accent-primary);
|
||||
background: rgba(var(--accent-primary-rgb), 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.close-button {
|
||||
padding: 8px;
|
||||
border: none;
|
||||
background: rgba(var(--accent-primary-rgb), 0.08);
|
||||
color: $text-secondary;
|
||||
cursor: pointer;
|
||||
border-radius: $radius-sm;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
color: $text-primary;
|
||||
background: rgba(var(--accent-primary-rgb), 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.drawer-pane {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useFilesStore } from '@/stores/hermes/files'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NButton } from 'naive-ui'
|
||||
import FileTree from '@/components/hermes/files/FileTree.vue'
|
||||
import FileBreadcrumb from '@/components/hermes/files/FileBreadcrumb.vue'
|
||||
import FileToolbar from '@/components/hermes/files/FileToolbar.vue'
|
||||
import FileList from '@/components/hermes/files/FileList.vue'
|
||||
import FileContextMenu from '@/components/hermes/files/FileContextMenu.vue'
|
||||
import FileEditor from '@/components/hermes/files/FileEditor.vue'
|
||||
import FilePreview from '@/components/hermes/files/FilePreview.vue'
|
||||
import FileUploadModal from '@/components/hermes/files/FileUploadModal.vue'
|
||||
import FileRenameModal from '@/components/hermes/files/FileRenameModal.vue'
|
||||
import type { FileEntry } from '@/api/hermes/files'
|
||||
|
||||
const filesStore = useFilesStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const contextMenuRef = ref<InstanceType<typeof FileContextMenu> | null>(null)
|
||||
const showUpload = ref(false)
|
||||
const showRenameModal = ref(false)
|
||||
const renameMode = ref<'newFile' | 'newFolder' | 'rename'>('newFile')
|
||||
const renameEntry = ref<FileEntry | null>(null)
|
||||
const showSidebar = ref(false)
|
||||
|
||||
function handleContextMenu(e: MouseEvent, entry: FileEntry) {
|
||||
contextMenuRef.value?.show(e, entry)
|
||||
}
|
||||
|
||||
function handleShowNewFile() {
|
||||
renameMode.value = 'newFile'
|
||||
renameEntry.value = null
|
||||
showRenameModal.value = true
|
||||
}
|
||||
|
||||
function handleShowNewFolder() {
|
||||
renameMode.value = 'newFolder'
|
||||
renameEntry.value = null
|
||||
showRenameModal.value = true
|
||||
}
|
||||
|
||||
function handleRename(entry: FileEntry) {
|
||||
renameMode.value = 'rename'
|
||||
renameEntry.value = entry
|
||||
showRenameModal.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
filesStore.fetchEntries('')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="files-panel-drawer">
|
||||
<div
|
||||
v-if="showSidebar"
|
||||
class="sidebar-overlay"
|
||||
@click="showSidebar = false"
|
||||
></div>
|
||||
<div
|
||||
class="files-tree-panel"
|
||||
:class="{ 'mobile-visible': showSidebar }"
|
||||
>
|
||||
<FileTree />
|
||||
</div>
|
||||
<div class="files-main-panel">
|
||||
<div class="main-toolbar">
|
||||
<NButton
|
||||
size="small"
|
||||
@click="showSidebar = !showSidebar"
|
||||
class="sidebar-toggle"
|
||||
>
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<line x1="9" y1="3" x2="9" y2="21" />
|
||||
</svg>
|
||||
</template>
|
||||
{{ t('files.fileTree') }}
|
||||
</NButton>
|
||||
<FileToolbar
|
||||
@show-new-file="handleShowNewFile"
|
||||
@show-new-folder="handleShowNewFolder"
|
||||
@show-upload="showUpload = true"
|
||||
/>
|
||||
</div>
|
||||
<FileBreadcrumb />
|
||||
<div class="files-content">
|
||||
<FileEditor v-if="filesStore.editingFile" />
|
||||
<FilePreview v-else-if="filesStore.previewFile" />
|
||||
<FileList v-else @contextmenu-entry="handleContextMenu" />
|
||||
</div>
|
||||
</div>
|
||||
<FileContextMenu ref="contextMenuRef" @rename="handleRename" />
|
||||
<FileUploadModal v-model:show="showUpload" />
|
||||
<FileRenameModal v-model:show="showRenameModal" :mode="renameMode" :entry="renameEntry" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.files-panel-drawer {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 50;
|
||||
|
||||
@media (min-width: $breakpoint-mobile + 1) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.files-tree-panel {
|
||||
width: 200px;
|
||||
min-width: 150px;
|
||||
max-width: 300px;
|
||||
border-right: 1px solid $border-color;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 80%;
|
||||
max-width: 300px;
|
||||
z-index: 51;
|
||||
background: $bg-card;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
&.mobile-visible {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.files-main-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
flex-shrink: 0;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
gap: 4px;
|
||||
padding: 8px 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
@media (min-width: $breakpoint-mobile + 1) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
font-size: 12px;
|
||||
padding: 0 8px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.files-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,788 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from "vue";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { getApiKey, getBaseUrlValue } from "@/api/client";
|
||||
import { NButton, NPopconfirm, NTooltip, NSelect, useMessage } from "naive-ui";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { ITheme } from "@xterm/xterm";
|
||||
|
||||
const { t } = useI18n();
|
||||
const message = useMessage();
|
||||
|
||||
// ─── Terminal themes ────────────────────────────────────────────
|
||||
|
||||
const TERMINAL_THEMES: Record<string, { label: string; theme: ITheme }> = {
|
||||
default: {
|
||||
label: "Default",
|
||||
theme: {
|
||||
background: "#1a1a2e",
|
||||
foreground: "#e0e0e0",
|
||||
cursor: "#4cc9f0",
|
||||
cursorAccent: "#1a1a2e",
|
||||
selectionBackground: "rgba(76, 201, 240, 0.3)",
|
||||
black: "#000000", red: "#e06c75", green: "#98c379", yellow: "#e5c07b",
|
||||
blue: "#61afef", magenta: "#c678dd", cyan: "#56b6c2", white: "#abb2bf",
|
||||
brightBlack: "#5c6370", brightRed: "#e06c75", brightGreen: "#98c379",
|
||||
brightYellow: "#e5c07b", brightBlue: "#61afef", brightMagenta: "#c678dd",
|
||||
brightCyan: "#56b6c2", brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
"solarized-dark": {
|
||||
label: "Solarized Dark",
|
||||
theme: {
|
||||
background: "#002b36", foreground: "#839496",
|
||||
cursor: "#93a1a1", cursorAccent: "#002b36",
|
||||
selectionBackground: "rgba(147, 161, 161, 0.3)",
|
||||
black: "#073642", red: "#dc322f", green: "#859900", yellow: "#b58900",
|
||||
blue: "#268bd2", magenta: "#d33682", cyan: "#2aa198", white: "#eee8d5",
|
||||
brightBlack: "#002b36", brightRed: "#cb4b16", brightGreen: "#586e75",
|
||||
brightYellow: "#657b83", brightBlue: "#839496", brightMagenta: "#6c71c4",
|
||||
brightCyan: "#93a1a1", brightWhite: "#fdf6e3",
|
||||
},
|
||||
},
|
||||
"tokyo-night": {
|
||||
label: "Tokyo Night",
|
||||
theme: {
|
||||
background: "#1a1b26", foreground: "#a9b1d6",
|
||||
cursor: "#c0caf5", cursorAccent: "#1a1b26",
|
||||
selectionBackground: "rgba(192, 202, 245, 0.2)",
|
||||
black: "#15161e", red: "#f7768e", green: "#9ece6a", yellow: "#e0af68",
|
||||
blue: "#7aa2f7", magenta: "#bb9af7", cyan: "#7dcfff", white: "#a9b1d6",
|
||||
brightBlack: "#414868", brightRed: "#f7768e", brightGreen: "#9ece6a",
|
||||
brightYellow: "#e0af68", brightBlue: "#7aa2f7", brightMagenta: "#bb9af7",
|
||||
brightCyan: "#7dcfff", brightWhite: "#c0caf5",
|
||||
},
|
||||
},
|
||||
"github-dark": {
|
||||
label: "GitHub Dark",
|
||||
theme: {
|
||||
background: "#0d1117", foreground: "#c9d1d9",
|
||||
cursor: "#58a6ff", cursorAccent: "#0d1117",
|
||||
selectionBackground: "rgba(88, 166, 255, 0.25)",
|
||||
black: "#484f58", red: "#ff7b72", green: "#7ee787", yellow: "#ffa657",
|
||||
blue: "#79c0ff", magenta: "#d2a8ff", cyan: "#a5d6ff", white: "#c9d1d9",
|
||||
brightBlack: "#6e7681", brightRed: "#ffa198", brightGreen: "#56d364",
|
||||
brightYellow: "#e3b341", brightBlue: "#58a6ff", brightMagenta: "#bc8cff",
|
||||
brightCyan: "#79c0ff", brightWhite: "#f0f6fc",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const STORAGE_KEY_THEME = "hermes_terminal_theme";
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────
|
||||
|
||||
interface SessionInfo {
|
||||
id: string;
|
||||
shell: string;
|
||||
pid: number;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
exited: boolean;
|
||||
}
|
||||
|
||||
// ─── State ──────────────────────────────────────────────────────
|
||||
|
||||
const terminalRef = ref<HTMLDivElement | null>(null);
|
||||
const sessions = ref<SessionInfo[]>([]);
|
||||
const activeSessionId = ref<string | null>(null);
|
||||
const selectedTheme = ref(localStorage.getItem(STORAGE_KEY_THEME) || "default");
|
||||
const connectionError = ref<string | null>(null);
|
||||
const isConnecting = ref(false);
|
||||
const showSidebar = ref(false);
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
const termMap = new Map<string, { term: Terminal; fitAddon: FitAddon; opened: boolean }>();
|
||||
let activeTerm: Terminal | null = null;
|
||||
let activeFitAddon: FitAddon | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let reconnectAttempts = 0;
|
||||
const MAX_RECONNECT_ATTEMPTS = 3;
|
||||
|
||||
// ─── Computed ──────────────────────────────────────────────────
|
||||
|
||||
const activeSession = computed(
|
||||
() => sessions.value.find((s) => s.id === activeSessionId.value) || null,
|
||||
);
|
||||
|
||||
const themeOptions = computed(() =>
|
||||
Object.entries(TERMINAL_THEMES).map(([key, val]) => ({
|
||||
label: val.label,
|
||||
value: key,
|
||||
})),
|
||||
);
|
||||
|
||||
const terminalBg = computed(
|
||||
() => TERMINAL_THEMES[selectedTheme.value]?.theme.background ?? "#1a1a2e",
|
||||
);
|
||||
|
||||
// ─── WebSocket ──────────────────────────────────────────────────
|
||||
|
||||
function buildWsUrl(): string {
|
||||
const token = getApiKey();
|
||||
const base = getBaseUrlValue();
|
||||
const wsProtocol = base
|
||||
? base.startsWith("https")
|
||||
? "wss:"
|
||||
: "ws:"
|
||||
: location.protocol === "https:"
|
||||
? "wss:"
|
||||
: "ws:";
|
||||
|
||||
if (base) {
|
||||
return `${wsProtocol}//${new URL(base).host}/api/hermes/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
|
||||
}
|
||||
|
||||
const host = import.meta.env.DEV
|
||||
? `${location.hostname}:8648`
|
||||
: location.host;
|
||||
return `${wsProtocol}//${host}/api/hermes/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||
connectionError.value = t('terminal.connectionFailed');
|
||||
isConnecting.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const url = buildWsUrl();
|
||||
connectionError.value = null;
|
||||
isConnecting.value = true;
|
||||
reconnectAttempts++;
|
||||
|
||||
ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectAttempts = 0;
|
||||
isConnecting.value = false;
|
||||
connectionError.value = null;
|
||||
// Server auto-creates the first session
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = typeof event.data === "string" ? event.data : "";
|
||||
if (data.charCodeAt(0) === 0x7b) {
|
||||
try {
|
||||
handleControl(JSON.parse(data));
|
||||
} catch {}
|
||||
} else {
|
||||
activeTerm?.write(data);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
isConnecting.value = false;
|
||||
|
||||
// 如果是正常关闭(code 1000)或认证失败,不重连
|
||||
if (event.code === 1000 || event.code === 1003 || event.code === 1008) {
|
||||
connectionError.value = t('terminal.connectionClosed');
|
||||
return;
|
||||
}
|
||||
|
||||
// 其他情况尝试重连
|
||||
setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('[Terminal] WebSocket error:', error);
|
||||
connectionError.value = t('terminal.connectionError');
|
||||
};
|
||||
}
|
||||
|
||||
function send(data: object | string) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(typeof data === "string" ? data : JSON.stringify(data));
|
||||
}
|
||||
|
||||
// ─── Control message handlers ──────────────────────────────────
|
||||
|
||||
function handleControl(msg: any) {
|
||||
switch (msg.type) {
|
||||
case "created":
|
||||
sessions.value.push({
|
||||
id: msg.id,
|
||||
shell: msg.shell,
|
||||
pid: msg.pid,
|
||||
title: `${msg.shell} #${sessions.value.length + 1}`,
|
||||
createdAt: Date.now(),
|
||||
exited: false,
|
||||
});
|
||||
switchSession(msg.id);
|
||||
break;
|
||||
|
||||
case "exited": {
|
||||
const s = sessions.value.find((s) => s.id === msg.id);
|
||||
if (s) {
|
||||
s.exited = true;
|
||||
if (activeSessionId.value === msg.id) {
|
||||
activeTerm?.write(
|
||||
`\r\n\x1b[90m[${t("terminal.processExited", { code: msg.exitCode })}]\x1b[0m\r\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "error":
|
||||
message.error(msg.message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Session actions ────────────────────────────────────────────
|
||||
|
||||
function createSession() {
|
||||
send({ type: "create" });
|
||||
}
|
||||
|
||||
function getOrCreateTerm(id: string): { term: Terminal; fitAddon: FitAddon } {
|
||||
let entry = termMap.get(id);
|
||||
if (!entry) {
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
theme: { ...TERMINAL_THEMES[selectedTheme.value].theme },
|
||||
});
|
||||
const fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(new WebLinksAddon());
|
||||
term.onData((data) => {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(data);
|
||||
}
|
||||
});
|
||||
entry = { term, fitAddon, opened: false };
|
||||
termMap.set(id, entry);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
function switchSession(id: string) {
|
||||
if (activeSessionId.value === id) return;
|
||||
activeSessionId.value = id;
|
||||
const entry = getOrCreateTerm(id);
|
||||
activeTerm = entry.term;
|
||||
activeFitAddon = entry.fitAddon;
|
||||
mountActiveTerminal();
|
||||
send({ type: "switch", sessionId: id });
|
||||
}
|
||||
|
||||
function closeSession(id: string) {
|
||||
send({ type: "close", sessionId: id });
|
||||
sessions.value = sessions.value.filter((s) => s.id !== id);
|
||||
const entry = termMap.get(id);
|
||||
if (entry) {
|
||||
entry.term.dispose();
|
||||
termMap.delete(id);
|
||||
}
|
||||
if (activeSessionId.value === id) {
|
||||
activeSessionId.value = sessions.value.length > 0 ? sessions.value[0].id : null;
|
||||
activeTerm = null;
|
||||
activeFitAddon = null;
|
||||
if (activeSessionId.value) {
|
||||
switchSession(activeSessionId.value);
|
||||
} else {
|
||||
unmountActiveTerminal();
|
||||
createSession();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Terminal mount/unmount ─────────────────────────────────────
|
||||
|
||||
function mountActiveTerminal() {
|
||||
if (!terminalRef.value) return;
|
||||
const container = terminalRef.value;
|
||||
while (container.firstChild) container.removeChild(container.firstChild);
|
||||
|
||||
const entry = termMap.get(activeSessionId.value!);
|
||||
if (!entry) return;
|
||||
|
||||
if (!entry.opened) {
|
||||
entry.term.open(container);
|
||||
entry.opened = true;
|
||||
} else {
|
||||
const termEl = entry.term.element;
|
||||
if (termEl) {
|
||||
container.appendChild(termEl);
|
||||
}
|
||||
}
|
||||
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
tryFit();
|
||||
sendResize();
|
||||
});
|
||||
resizeObserver.observe(terminalRef.value);
|
||||
|
||||
setTimeout(() => tryFit(), 50);
|
||||
setTimeout(() => tryFit(), 200);
|
||||
}
|
||||
|
||||
function unmountActiveTerminal() {
|
||||
if (!terminalRef.value) return;
|
||||
const container = terminalRef.value;
|
||||
while (container.firstChild) container.removeChild(container.firstChild);
|
||||
}
|
||||
|
||||
function tryFit() {
|
||||
if (!activeFitAddon) return;
|
||||
try {
|
||||
activeFitAddon.fit();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function sendResize() {
|
||||
if (!activeTerm || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
try {
|
||||
send({
|
||||
type: "resize",
|
||||
cols: activeTerm.cols,
|
||||
rows: activeTerm.rows,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ─── Theme ───────────────────────────────────────────────────────
|
||||
|
||||
function applyTheme(themeName: string) {
|
||||
selectedTheme.value = themeName;
|
||||
localStorage.setItem(STORAGE_KEY_THEME, themeName);
|
||||
const themeObj = TERMINAL_THEMES[themeName]?.theme;
|
||||
if (!themeObj) return;
|
||||
for (const entry of termMap.values()) {
|
||||
entry.term.options.theme = { ...themeObj };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
function formatTime(ts: number) {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
// ─── Lifecycle ──────────────────────────────────────────────────
|
||||
|
||||
onMounted(() => {
|
||||
connect();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
unmountActiveTerminal();
|
||||
for (const entry of termMap.values()) {
|
||||
entry.term.dispose();
|
||||
}
|
||||
termMap.clear();
|
||||
activeTerm = null;
|
||||
activeFitAddon = null;
|
||||
ws?.close();
|
||||
ws = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="terminal-panel-drawer">
|
||||
<div
|
||||
v-if="showSidebar"
|
||||
class="sidebar-overlay"
|
||||
@click="showSidebar = false"
|
||||
></div>
|
||||
<div
|
||||
class="terminal-sidebar"
|
||||
:class="{ 'mobile-visible': showSidebar }"
|
||||
>
|
||||
<div class="sidebar-header">
|
||||
<span class="sidebar-title">{{ t("terminal.sessions") }}</span>
|
||||
<NTooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<NButton quaternary size="tiny" @click="createSession" circle>
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</template>
|
||||
</NButton>
|
||||
</template>
|
||||
{{ t("terminal.newTab") }}
|
||||
</NTooltip>
|
||||
</div>
|
||||
<div class="session-list">
|
||||
<div v-if="connectionError" class="session-error">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<span>{{ connectionError }}</span>
|
||||
<NButton size="tiny" @click="connect">{{ t("common.retry") }}</NButton>
|
||||
</div>
|
||||
<div v-else-if="sessions.length === 0" class="session-empty">
|
||||
<template v-if="isConnecting">
|
||||
{{ t("common.loading") }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ t("terminal.noSessions") }}
|
||||
</template>
|
||||
</div>
|
||||
<button
|
||||
v-for="s in sessions"
|
||||
:key="s.id"
|
||||
class="session-item"
|
||||
:class="{ active: s.id === activeSessionId, exited: s.exited }"
|
||||
@click="switchSession(s.id)"
|
||||
>
|
||||
<div class="session-item-content">
|
||||
<span class="session-item-title">{{ s.title }}</span>
|
||||
<span class="session-item-meta">
|
||||
<span class="session-item-shell">{{ s.shell }}</span>
|
||||
<span v-if="s.exited" class="session-item-status">{{
|
||||
t("terminal.sessionExited")
|
||||
}}</span>
|
||||
<span v-else class="session-item-time">{{
|
||||
formatTime(s.createdAt)
|
||||
}}</span>
|
||||
</span>
|
||||
</div>
|
||||
<NPopconfirm v-if="sessions.length > 1" @positive-click="closeSession(s.id)">
|
||||
<template #trigger>
|
||||
<button class="session-item-delete" @click.stop>
|
||||
<svg width="12" height="12" 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>
|
||||
</template>
|
||||
{{ t("terminal.closeSession") }}
|
||||
</NPopconfirm>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="terminal-main">
|
||||
<header class="terminal-header">
|
||||
<span v-if="activeSession" class="header-session-title">{{
|
||||
activeSession.title
|
||||
}}</span>
|
||||
<div class="header-actions">
|
||||
<NButton
|
||||
size="small"
|
||||
@click="showSidebar = !showSidebar"
|
||||
class="sidebar-toggle"
|
||||
>
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<line x1="9" y1="3" x2="9" y2="21" />
|
||||
</svg>
|
||||
</template>
|
||||
{{ t("terminal.sessions") }}
|
||||
</NButton>
|
||||
<NSelect
|
||||
:value="selectedTheme"
|
||||
:options="themeOptions"
|
||||
size="small"
|
||||
:consistent-menu-width="false"
|
||||
class="theme-select"
|
||||
@update:value="applyTheme"
|
||||
/>
|
||||
<NButton size="small" @click="createSession">
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</template>
|
||||
{{ t("terminal.newTab") }}
|
||||
</NButton>
|
||||
</div>
|
||||
</header>
|
||||
<div class="terminal-container">
|
||||
<div ref="terminalRef" class="terminal-xterm" :style="{ backgroundColor: terminalBg }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.terminal-panel-drawer {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 50;
|
||||
|
||||
@media (min-width: $breakpoint-mobile + 1) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-sidebar {
|
||||
width: 180px;
|
||||
border-right: 1px solid $border-color;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 80%;
|
||||
max-width: 300px;
|
||||
z-index: 51;
|
||||
background: $bg-card;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
&.mobile-visible {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: $text-muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.session-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.session-empty {
|
||||
padding: 16px 8px;
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.session-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px 12px;
|
||||
font-size: 12px;
|
||||
color: $error;
|
||||
text-align: center;
|
||||
|
||||
svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.session-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: $radius-sm;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: $text-secondary;
|
||||
transition: all $transition-fast;
|
||||
margin-bottom: 2px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--accent-primary-rgb), 0.06);
|
||||
color: $text-primary;
|
||||
|
||||
.session-item-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba(var(--accent-primary-rgb), 0.1);
|
||||
color: $text-primary;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.exited {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.session-item-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.session-item-title {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.session-item-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.session-item-shell {
|
||||
font-size: 9px;
|
||||
color: $accent-primary;
|
||||
background: rgba(var(--accent-primary-rgb), 0.08);
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.session-item-time,
|
||||
.session-item-status {
|
||||
font-size: 10px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.session-item-delete {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
padding: 2px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: $text-muted;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
transition: all $transition-fast;
|
||||
|
||||
&:hover {
|
||||
color: $error;
|
||||
background: rgba(var(--error-rgb), 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-session-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.theme-select {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
@media (min-width: $breakpoint-mobile + 1) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-container {
|
||||
flex: 1;
|
||||
margin: 8px;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.terminal-xterm {
|
||||
flex: 1;
|
||||
border-radius: $radius-md;
|
||||
overflow: hidden;
|
||||
border: 1px solid $border-color;
|
||||
|
||||
:deep(.xterm) {
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
:deep(.xterm-viewport) {
|
||||
overflow-y: scroll !important;
|
||||
scrollbar-width: none !important;
|
||||
-ms-overflow-style: none !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
:deep(.xterm-viewport::-webkit-scrollbar) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:deep(.xterm-screen) {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
:deep(.xterm-scrollable-element) {
|
||||
scrollbar-width: none !important;
|
||||
-ms-overflow-style: none !important;
|
||||
}
|
||||
|
||||
:deep(.xterm-scrollable-element::-webkit-scrollbar) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user