[codex] Fix profile-aware session deep links (#962)

* feat: add session deep links for chats

* feat: add deep links for history and group chat

* Fix profile-aware session deep links

---------

Co-authored-by: Maxim Kirilyuk <werserk@inbox.ru>
This commit is contained in:
ekko
2026-05-24 10:55:55 +08:00
committed by GitHub
parent 8d261c3fa6
commit df41d6b051
22 changed files with 871 additions and 63 deletions
@@ -16,6 +16,7 @@ import {
type DropdownOption,
} from "naive-ui";
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";
@@ -30,6 +31,7 @@ const chatStore = useChatStore();
const appStore = useAppStore();
const profilesStore = useProfilesStore();
const sessionBrowserPrefsStore = useSessionBrowserPrefsStore();
const router = useRouter();
const message = useMessage();
const { t } = useI18n();
@@ -58,8 +60,13 @@ const showSessions = ref(
let mobileQuery: MediaQueryList | null = null;
const isMobile = ref(false);
function handleSessionClick(sessionId: string) {
chatStore.switchSession(sessionId);
async function handleSessionClick(sessionId: string) {
const session = chatStore.sessions.find((item) => item.id === sessionId);
await router.push({
name: "hermes.session",
params: { sessionId },
query: session?.profile ? { profile: session.profile } : undefined,
});
if (mobileQuery?.matches) showSessions.value = false;
}
@@ -242,12 +249,17 @@ function handleNewChatProviderChange(value: string) {
newChatModel.value = newChatModelOptions.value[0]?.value || "";
}
function confirmNewChat() {
chatStore.newChat({
async function confirmNewChat() {
const session = chatStore.newChat({
profile: newChatProfile.value,
provider: newChatProvider.value,
model: newChatModel.value,
});
await router.push({
name: "hermes.session",
params: { sessionId: session.id },
query: session.profile ? { profile: session.profile } : undefined,
});
showNewChatModal.value = false;
}
@@ -255,6 +267,28 @@ function handleApproval(choice: "once" | "session" | "always" | "deny") {
chatStore.respondApproval(choice);
}
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: "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) {
@@ -397,6 +431,7 @@ const contextMenuOptions = computed(() => {
},
],
})
options.push({ label: t("chat.copySessionLink"), key: "copy-link" })
options.push({ label: t("chat.copySessionId"), key: "copy-id" })
return options
});
@@ -428,7 +463,9 @@ async function handleContextMenuSelect(key: string) {
sessionBrowserPrefsStore.togglePinned(contextSessionId.value);
return;
}
if (key === "copy-id") {
if (key === "copy-link") {
copySessionLink(contextSessionId.value);
} else if (key === "copy-id") {
copySessionId(contextSessionId.value);
} else if (parseExportKey(key)) {
const { mode, ext } = parseExportKey(key)!;
@@ -1,17 +1,20 @@
<script setup lang="ts">
import { ref, computed, nextTick, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage, NInput, NButton, NSpace, NSelect, NPopover, NPopconfirm, NInputNumber } from 'naive-ui'
import { useRouter } from 'vue-router'
import { useMessage, NInput, NButton, NSpace, NSelect, NPopover, NPopconfirm, NInputNumber, NDropdown, type DropdownOption } from 'naive-ui'
import { useGroupChatStore } from '@/stores/hermes/group-chat'
import { useProfilesStore } from '@/stores/hermes/profiles'
import { updateRoomConfig, forceCompress } from '@/api/hermes/group-chat'
import GroupMessageList from './GroupMessageList.vue'
import GroupChatInput from './GroupChatInput.vue'
import ProfileAvatar from '@/components/hermes/profiles/ProfileAvatar.vue'
import { copyToClipboard } from '@/utils/clipboard'
import type { Attachment } from '@/stores/hermes/chat'
import type { RoomAgent } from '@/api/hermes/group-chat'
const { t } = useI18n()
const router = useRouter()
const message = useMessage()
const store = useGroupChatStore()
const profilesStore = useProfilesStore()
@@ -29,6 +32,10 @@ const agentDescription = ref('')
const cloneSourceRoomId = ref<string | null>(null)
const cloneRoomName = ref('')
const cloneInviteCode = ref('')
const contextRoomId = ref<string | null>(null)
const showRoomContextMenu = ref(false)
const roomContextMenuX = ref(0)
const roomContextMenuY = ref(0)
const profileOptions = computed(() =>
profilesStore.profiles.map(p => ({ label: p.name, value: p.name }))
@@ -94,7 +101,7 @@ async function handleCreateRoom(name: string, inviteCode: string, userName: stri
const failureMessage = formatAgentFailures(res.agentResults)
if (failureMessage) message.warning(failureMessage)
else message.success(t('groupChat.roomCreated'))
await store.joinRoom(res.room.id)
await router.push({ name: 'hermes.groupChatRoom', params: { roomId: res.room.id } })
} catch {
message.error(t('common.saveFailed'))
}
@@ -103,12 +110,54 @@ async function handleCreateRoom(name: string, inviteCode: string, userName: stri
async function handleDeleteRoom(roomId: string) {
try {
await store.deleteRoom(roomId)
if (store.currentRoomId === roomId) {
await router.replace({ name: 'hermes.groupChat' })
}
message.success(t('groupChat.roomDeleted'))
} catch {
message.error(t('common.saveFailed'))
}
}
function buildRoomUrl(roomId: string) {
const href = router.resolve({ name: 'hermes.groupChatRoom', params: { roomId } }).href
return `${window.location.origin}${window.location.pathname}${href}`
}
async function copyRoomLink(roomId: string) {
const ok = await copyToClipboard(buildRoomUrl(roomId))
if (ok) message.success(t('common.copied'))
else message.error(t('common.copied') + ' ✗')
}
const roomContextMenuOptions = computed<DropdownOption[]>(() => [
{ label: t('groupChat.copyRoomLink'), key: 'copy-link' },
{ label: t('groupChat.cloneRoom'), key: 'clone-room' },
])
function handleRoomContextMenu(event: MouseEvent, roomId: string) {
event.preventDefault()
contextRoomId.value = roomId
roomContextMenuX.value = event.clientX
roomContextMenuY.value = event.clientY
showRoomContextMenu.value = true
}
function handleRoomContextClickOutside() {
showRoomContextMenu.value = false
}
function handleRoomContextSelect(key: string) {
showRoomContextMenu.value = false
const roomId = contextRoomId.value
if (!roomId) return
if (key === 'copy-link') {
void copyRoomLink(roomId)
} else if (key === 'clone-room') {
handleOpenCloneRoom(roomId)
}
}
function handleOpenCloneRoom(roomId: string) {
const room = store.rooms.find(r => r.id === roomId)
cloneSourceRoomId.value = roomId
@@ -128,7 +177,7 @@ async function confirmCloneRoom() {
cloneSourceRoomId.value = null
cloneRoomName.value = ''
cloneInviteCode.value = ''
await store.joinRoom(res.room.id)
await router.push({ name: 'hermes.groupChatRoom', params: { roomId: res.room.id } })
const failureMessage = formatAgentFailures(res.agentResults)
if (failureMessage) message.warning(failureMessage)
else message.success(t('groupChat.roomCloned'))
@@ -153,7 +202,7 @@ async function handleClearRoomContext() {
async function handleSelectRoom(roomId: string) {
try {
await store.joinRoom(roomId)
await router.push({ name: 'hermes.groupChatRoom', params: { roomId } })
if (window.innerWidth <= 768) showSidebar.value = false
} catch {
message.error(t('groupChat.joinFailed'))
@@ -299,6 +348,7 @@ watch(() => store.sortedMessages.length, async () => {
class="room-item"
:class="{ active: store.currentRoomId === room.id }"
@click="handleSelectRoom(room.id)"
@contextmenu="handleRoomContextMenu($event, room.id)"
>
<svg class="room-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
@@ -308,11 +358,6 @@ watch(() => store.sortedMessages.length, async () => {
<span v-if="room.inviteCode" class="room-code">{{ room.inviteCode }}</span>
<span class="room-tokens">{{ formatTokens(room.totalTokens || 0) }}</span>
</div>
<button class="room-action-btn" :title="t('groupChat.cloneRoom')" @click.stop="handleOpenCloneRoom(room.id)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="8" y="8" width="12" height="12" rx="2" /><path d="M4 16V6a2 2 0 0 1 2-2h10" />
</svg>
</button>
<NPopconfirm @positive-click="handleDeleteRoom(room.id)">
<template #trigger>
<button class="room-action-btn danger" @click.stop>
@@ -328,6 +373,17 @@ watch(() => store.sortedMessages.length, async () => {
</div>
</div>
<NDropdown
placement="bottom-start"
trigger="manual"
:x="roomContextMenuX"
:y="roomContextMenuY"
:options="roomContextMenuOptions"
:show="showRoomContextMenu"
@select="handleRoomContextSelect"
@clickoutside="handleRoomContextClickOutside"
/>
<!-- Main chat area -->
<div class="chat-main">
<div class="chat-header">
@@ -18,7 +18,12 @@ const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const { openSessionSearch } = useSessionSearch();
const selectedKey = computed(() => route.name as string);
const selectedKey = computed(() => {
if (route.name === "hermes.session") return "hermes.chat";
if (route.name === "hermes.historySession") return "hermes.history";
if (route.name === "hermes.groupChatRoom") return "hermes.groupChat";
return route.name as string;
});
const isSuperAdmin = computed(() => isStoredSuperAdmin());
const logoPath = '/logo.png';