mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
refactor(web): consolidate storage and split root state and app shell into composables (#979)
* refactor(web): consolidate localStorage access and split appearance/notification modules * refactor(web): extract task polling into useTaskPoller module * refactor(web): split useKimiWebClient into workspace/sideChat/modelProvider modules * refactor(web): extract App.vue composables for page title, auth, sidebar, detail panel and file preview * refactor(web): funnel sessions and activeSessionId mutations through setters * refactor(web): funnel messagesBySession mutations through setters * refactor(web): move FileData type to types.ts to fix type-aware lint
This commit is contained in:
parent
d4ae02d82e
commit
8c6cade69e
14 changed files with 3513 additions and 2847 deletions
5
.changeset/web-storage-appearance-notification.md
Normal file
5
.changeset/web-storage-appearance-notification.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Consolidate web client localStorage access and split the root state store and app shell into focused composables.
|
||||
|
|
@ -1,16 +1,15 @@
|
|||
<!-- apps/kimi-web/src/App.vue -->
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch, watchEffect } from 'vue';
|
||||
import { computed, nextTick, onMounted, onUnmounted, provide, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Sidebar from './components/Sidebar.vue';
|
||||
import ResizeHandle from './components/ResizeHandle.vue';
|
||||
import ConversationPane from './components/ConversationPane.vue';
|
||||
import FilePreview, { type FileData } from './components/FilePreview.vue';
|
||||
import FilePreview from './components/FilePreview.vue';
|
||||
import ThinkingPanel from './components/ThinkingPanel.vue';
|
||||
import AgentDetailPanel from './components/AgentDetailPanel.vue';
|
||||
import SideChatPanel from './components/SideChatPanel.vue';
|
||||
import DiffView from './components/DiffView.vue';
|
||||
import type { AgentMember } from './types';
|
||||
import ModelPicker from './components/ModelPicker.vue';
|
||||
import ProviderManager from './components/ProviderManager.vue';
|
||||
import LoginDialog from './components/LoginDialog.vue';
|
||||
|
|
@ -28,10 +27,13 @@ import GlobalLoading from './components/GlobalLoading.vue';
|
|||
import DebugPanel from './debug/DebugPanel.vue';
|
||||
import { isTraceEnabled } from './debug/trace';
|
||||
import { useKimiWebClient } from './composables/useKimiWebClient';
|
||||
import { useAuthGate } from './composables/useAuthGate';
|
||||
import { usePageTitle } from './composables/usePageTitle';
|
||||
import { useSidebarLayout } from './composables/useSidebarLayout';
|
||||
import { useFilePreview, type DetailTarget } from './composables/useFilePreview';
|
||||
import { useDetailPanel } from './composables/useDetailPanel';
|
||||
import { useIsMobile } from './composables/useIsMobile';
|
||||
import type { AppConfig, ThinkingLevel } from './api/types';
|
||||
import type { FilePreviewRequest, ToolMedia } from './types';
|
||||
import { safeGetString, safeSetString, STORAGE_KEYS } from './lib/storage';
|
||||
|
||||
const client = useKimiWebClient();
|
||||
provide('resolveImage', client.resolveImageUrl);
|
||||
|
|
@ -64,89 +66,14 @@ const running = computed(() => client.activity.value !== 'idle');
|
|||
|
||||
// Auth readiness gates the main app. Once the first load finishes and auth is
|
||||
// still missing, show a full-page login entry instead of an in-app banner.
|
||||
const authReady = computed(() => client.authReady.value);
|
||||
const showAuthGate = computed(() => client.initialized.value && !authReady.value);
|
||||
const LOGIN_PATH = '/login';
|
||||
const authReturnPath = ref<string | null>(null);
|
||||
const authLogoRef = ref<SVGSVGElement | null>(null);
|
||||
let authLogoBlinkTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function currentPathWithSuffix(): string {
|
||||
if (typeof window === 'undefined') return '/';
|
||||
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
}
|
||||
|
||||
function replaceBrowserPath(path: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.history.replaceState(window.history.state, '', path);
|
||||
}
|
||||
|
||||
watch(showAuthGate, (show) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (show) {
|
||||
if (window.location.pathname !== LOGIN_PATH) {
|
||||
authReturnPath.value = currentPathWithSuffix();
|
||||
replaceBrowserPath(LOGIN_PATH);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (window.location.pathname === LOGIN_PATH) {
|
||||
replaceBrowserPath(authReturnPath.value ?? '/');
|
||||
authReturnPath.value = null;
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
function blinkAuthLogo(): void {
|
||||
const el = authLogoRef.value;
|
||||
if (!el) return;
|
||||
el.classList.remove('blink-now');
|
||||
void el.getBoundingClientRect();
|
||||
el.classList.add('blink-now');
|
||||
if (authLogoBlinkTimer !== null) clearTimeout(authLogoBlinkTimer);
|
||||
authLogoBlinkTimer = setTimeout(() => {
|
||||
authLogoBlinkTimer = null;
|
||||
el.classList.remove('blink-now');
|
||||
}, 300);
|
||||
}
|
||||
const { showAuthGate, blinkAuthLogo } = useAuthGate({ client, authLogoRef });
|
||||
|
||||
|
||||
// Static page title (app name only). The session title and workspace name are
|
||||
// intentionally excluded so the tab title stays stable.
|
||||
// Prefix an animated spinner when the agent is running so users can see activity
|
||||
// at a glance.
|
||||
const SPINNER_FRAMES = ['◐', '◓', '◑', '◒'];
|
||||
const spinnerFrame = ref(0);
|
||||
let spinnerTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function startSpinner(): void {
|
||||
if (spinnerTimer !== null) return;
|
||||
spinnerFrame.value = 0;
|
||||
spinnerTimer = setInterval(() => {
|
||||
spinnerFrame.value = (spinnerFrame.value + 1) % SPINNER_FRAMES.length;
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function stopSpinner(): void {
|
||||
if (spinnerTimer !== null) {
|
||||
clearInterval(spinnerTimer);
|
||||
spinnerTimer = null;
|
||||
}
|
||||
spinnerFrame.value = 0;
|
||||
}
|
||||
|
||||
watch(running, (isRunning) => {
|
||||
if (isRunning) startSpinner();
|
||||
else stopSpinner();
|
||||
}, { immediate: true });
|
||||
|
||||
const pageTitle = computed<string>(() => {
|
||||
const prefix = running.value ? `${SPINNER_FRAMES[spinnerFrame.value]} ` : '';
|
||||
if (showAuthGate.value) return `${prefix}${t('app.authPageTitle')} - Kimi Code Web`;
|
||||
return `${prefix}Kimi Code Web`;
|
||||
});
|
||||
watchEffect(() => {
|
||||
if (typeof document !== 'undefined') document.title = pageTitle.value;
|
||||
});
|
||||
// intentionally excluded so the tab title stays stable. Prefixes an animated
|
||||
// spinner while the agent is running so activity is visible at a glance.
|
||||
usePageTitle({ running, showAuthGate });
|
||||
|
||||
// Thinking is on/off (TUI parity — no effort-level cycling). The /thinking
|
||||
// command flips between off and the backend default effort ('high').
|
||||
|
|
@ -175,21 +102,8 @@ onMounted(() => {
|
|||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', onGlobalKeydown, true);
|
||||
stopSpinner();
|
||||
if (authLogoBlinkTimer !== null) clearTimeout(authLogoBlinkTimer);
|
||||
});
|
||||
|
||||
// Escape closes whichever transient right-side detail panel is open.
|
||||
function closeOpenSidePanel(): boolean {
|
||||
if (detailTarget.value === 'thinking' && thinkingVisible.value) { closeThinkingPanel(); return true; }
|
||||
if (detailTarget.value === 'compaction' && compactionPanelVisible.value) { closeCompactionPanel(); return true; }
|
||||
if (detailTarget.value === 'agent' && agentPanelVisible.value) { closeAgentPanel(); return true; }
|
||||
if (detailTarget.value === 'file') { closeFilePreview(); return true; }
|
||||
if (detailTarget.value === 'diff') { closeDiffDetail(); return true; }
|
||||
if (detailTarget.value === 'btw') { closeSideChat(); return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
function onGlobalKeydown(e: KeyboardEvent): void {
|
||||
if (e.key !== 'Escape') return;
|
||||
// A modal dialog open on top of the side panel owns Escape — leave the event
|
||||
|
|
@ -205,383 +119,72 @@ function onGlobalKeydown(e: KeyboardEvent): void {
|
|||
// Layout: resizable session column. ResizeHandle owns the column width (with
|
||||
// localStorage persistence); we mirror it here to drive the App grid.
|
||||
// ---------------------------------------------------------------------------
|
||||
const SIDEBAR_WIDTH_KEY = STORAGE_KEYS.sidebarWidth;
|
||||
const SIDEBAR_COLLAPSED_KEY = STORAGE_KEYS.sidebarCollapsed;
|
||||
const SIDEBAR_DEFAULT = 270;
|
||||
const SIDEBAR_MIN = 170;
|
||||
const SIDEBAR_MAX = 420;
|
||||
const SIDEBAR_COLLAPSED_WIDTH = 36;
|
||||
|
||||
const sessionColWidth = ref(SIDEBAR_DEFAULT);
|
||||
const sidebarCollapsed = ref(false);
|
||||
const sideWidth = computed(() =>
|
||||
sidebarCollapsed.value ? SIDEBAR_COLLAPSED_WIDTH : sessionColWidth.value,
|
||||
);
|
||||
|
||||
function loadSidebarCollapsed(): void {
|
||||
try {
|
||||
sidebarCollapsed.value = safeGetString(SIDEBAR_COLLAPSED_KEY) === 'true';
|
||||
} catch {
|
||||
sidebarCollapsed.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function saveSidebarCollapsed(): void {
|
||||
try {
|
||||
safeSetString(SIDEBAR_COLLAPSED_KEY, String(sidebarCollapsed.value));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSidebarCollapse(): void {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value;
|
||||
saveSidebarCollapsed();
|
||||
}
|
||||
const {
|
||||
SIDEBAR_WIDTH_KEY,
|
||||
SIDEBAR_DEFAULT,
|
||||
SIDEBAR_MIN,
|
||||
SIDEBAR_MAX,
|
||||
sessionColWidth,
|
||||
sidebarCollapsed,
|
||||
sideWidth,
|
||||
loadSidebarCollapsed,
|
||||
toggleSidebarCollapse,
|
||||
} = useSidebarLayout();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified right-side detail layer. Only one detail is open at a time.
|
||||
// Unified right-side detail layer. Only one detail is open at a time. The
|
||||
// shared `detailTarget` ref lives here so the file-preview and detail-panel
|
||||
// composables can both claim the single right-side slot.
|
||||
// ---------------------------------------------------------------------------
|
||||
type DetailTarget = 'file' | 'diff' | 'thinking' | 'compaction' | 'agent' | 'btw';
|
||||
const detailTarget = ref<DetailTarget | null>(null);
|
||||
|
||||
const PREVIEW_WIDTH_KEY = 'kimi-web.file-preview-width';
|
||||
const PREVIEW_MIN = 320;
|
||||
|
||||
function previewAreaWidth(): number {
|
||||
if (typeof window === 'undefined') return PREVIEW_MIN * 2;
|
||||
return Math.max(0, window.innerWidth - sideWidth.value);
|
||||
}
|
||||
|
||||
function clampPreviewWidth(width: number): number {
|
||||
const max = Math.max(PREVIEW_MIN, previewAreaWidth() - PREVIEW_MIN);
|
||||
return Math.min(max, Math.max(PREVIEW_MIN, Math.round(width)));
|
||||
}
|
||||
|
||||
function defaultPreviewWidth(): number {
|
||||
return clampPreviewWidth(previewAreaWidth() / 2);
|
||||
}
|
||||
|
||||
const previewDefaultWidth = computed(() => defaultPreviewWidth());
|
||||
const previewMaxWidth = computed(() => Math.max(PREVIEW_MIN, previewAreaWidth() - PREVIEW_MIN));
|
||||
const previewWidth = ref(previewDefaultWidth.value);
|
||||
const previewTarget = ref<FilePreviewRequest | null>(null);
|
||||
const previewFile = ref<FileData | null>(null);
|
||||
const previewLoading = ref(false);
|
||||
const previewError = ref<string | null>(null);
|
||||
// Normalized workspace-relative path of the currently-open preview. Used for
|
||||
// the download URL so it matches the server's relative-path contract even when
|
||||
// the user opened the preview from an absolute path in the chat.
|
||||
const previewNormalizedPath = ref<string | null>(null);
|
||||
// Incremented on every openFilePreview call so a slower earlier request can't
|
||||
// overwrite the result of a later one (request-sequence guard).
|
||||
let previewRequestSeq = 0;
|
||||
|
||||
const previewDownloadUrl = computed(() => {
|
||||
const path = previewNormalizedPath.value;
|
||||
return path ? client.getFileDownloadUrl(path) : null;
|
||||
});
|
||||
const previewExternalActions = computed(() => previewTarget.value !== null);
|
||||
|
||||
function trimTrailingSlash(path: string): string {
|
||||
return path.length > 1 ? path.replace(/\/+$/, '') : path;
|
||||
}
|
||||
|
||||
function normalizeRelativePath(path: string): string {
|
||||
const out: string[] = [];
|
||||
for (const part of path.split(/[\\/]+/)) {
|
||||
if (!part || part === '.') continue;
|
||||
if (part === '..') {
|
||||
out.pop();
|
||||
continue;
|
||||
}
|
||||
out.push(part);
|
||||
}
|
||||
return out.join('/');
|
||||
}
|
||||
|
||||
function normalizePreviewPath(inputPath: string): { path: string } | { error: string } {
|
||||
const raw = inputPath.trim();
|
||||
if (!raw) return { error: t('filePreview.errors.emptyPath') };
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) {
|
||||
return { error: t('filePreview.errors.unsupportedPath') };
|
||||
}
|
||||
if (raw.startsWith('~')) {
|
||||
return { error: t('filePreview.errors.outsideWorkspace') };
|
||||
}
|
||||
|
||||
const cwd = trimTrailingSlash(client.status.value.cwd);
|
||||
if (raw.startsWith('/')) {
|
||||
if (!cwd || (raw !== cwd && !raw.startsWith(`${cwd}/`))) {
|
||||
return { error: t('filePreview.errors.outsideWorkspace') };
|
||||
}
|
||||
const relative = raw === cwd ? '' : raw.slice(cwd.length + 1);
|
||||
if (relative.split(/[\\/]+/).includes('..')) {
|
||||
return { error: t('filePreview.errors.outsideWorkspace') };
|
||||
}
|
||||
const path = normalizeRelativePath(relative);
|
||||
return path ? { path } : { error: t('filePreview.errors.isDirectory') };
|
||||
}
|
||||
|
||||
if (raw.split(/[\\/]+/).includes('..')) {
|
||||
return { error: t('filePreview.errors.outsideWorkspace') };
|
||||
}
|
||||
|
||||
const path = normalizeRelativePath(raw);
|
||||
return path ? { path } : { error: t('filePreview.errors.emptyPath') };
|
||||
}
|
||||
|
||||
async function openFilePreview(target: FilePreviewRequest): Promise<void> {
|
||||
const requestSeq = ++previewRequestSeq;
|
||||
detailTarget.value = 'file';
|
||||
previewFile.value = null;
|
||||
previewError.value = null;
|
||||
previewLoading.value = true;
|
||||
previewTarget.value = target;
|
||||
previewNormalizedPath.value = null;
|
||||
|
||||
const normalized = normalizePreviewPath(target.path);
|
||||
if ('error' in normalized) {
|
||||
previewLoading.value = false;
|
||||
previewError.value = normalized.error;
|
||||
return;
|
||||
}
|
||||
previewNormalizedPath.value = normalized.path;
|
||||
|
||||
try {
|
||||
const result = await client.readFileContent(normalized.path);
|
||||
// A newer openFilePreview started while this one was in flight — discard
|
||||
// the stale result so the right-side panel shows the latest file.
|
||||
if (requestSeq !== previewRequestSeq) return;
|
||||
if (result) {
|
||||
previewFile.value = { ...result, path: result.path || normalized.path };
|
||||
} else {
|
||||
previewFile.value = {
|
||||
path: normalized.path,
|
||||
content: '',
|
||||
encoding: 'utf-8',
|
||||
mime: 'text/plain',
|
||||
isBinary: false,
|
||||
size: 0,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
if (requestSeq !== previewRequestSeq) return;
|
||||
previewError.value = err instanceof Error ? err.message : t('filePreview.errors.loadFailed');
|
||||
} finally {
|
||||
if (requestSeq === previewRequestSeq) {
|
||||
previewLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mimeFromDataUrl(url: string): string | undefined {
|
||||
const match = /^data:([^;,]+)/i.exec(url);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
function openMediaPreview(media: ToolMedia): void {
|
||||
if (media.kind !== 'image') return;
|
||||
detailTarget.value = 'file';
|
||||
previewTarget.value = null;
|
||||
previewNormalizedPath.value = null;
|
||||
previewError.value = null;
|
||||
previewLoading.value = false;
|
||||
previewFile.value = {
|
||||
path: media.path ?? 'ReadMediaFile image',
|
||||
content: '',
|
||||
encoding: 'utf-8',
|
||||
mime: media.mimeType ?? mimeFromDataUrl(media.url) ?? 'image/*',
|
||||
sourceUrl: media.url,
|
||||
isBinary: true,
|
||||
size: media.bytes ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function closeFilePreview(): void {
|
||||
previewTarget.value = null;
|
||||
previewNormalizedPath.value = null;
|
||||
previewFile.value = null;
|
||||
previewError.value = null;
|
||||
previewLoading.value = false;
|
||||
if (detailTarget.value === 'file') detailTarget.value = null;
|
||||
}
|
||||
const {
|
||||
previewTarget,
|
||||
previewFile,
|
||||
previewLoading,
|
||||
previewError,
|
||||
previewDownloadUrl,
|
||||
previewExternalActions,
|
||||
openFilePreview,
|
||||
openMediaPreview,
|
||||
closeFilePreview,
|
||||
openPreviewInEditor,
|
||||
revealPreviewFile,
|
||||
} = useFilePreview({ client, detailTarget });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Thinking panel
|
||||
// Unified right-side detail layer (thinking / compaction / agent / diff / side
|
||||
// chat) plus the preview-panel width. Only one detail is open at a time.
|
||||
// ---------------------------------------------------------------------------
|
||||
const thinkingTarget = ref<{ turnId: string; blockIndex: number } | null>(null);
|
||||
|
||||
const thinkingPanelText = computed<string | null>(() => {
|
||||
const target = thinkingTarget.value;
|
||||
if (!target) return null;
|
||||
const turn = client.turns.value.find((tn) => tn.id === target.turnId);
|
||||
const blk = turn?.blocks?.[target.blockIndex];
|
||||
return blk?.kind === 'thinking' ? blk.thinking : null;
|
||||
});
|
||||
|
||||
const thinkingVisible = computed(() => thinkingPanelText.value !== null);
|
||||
|
||||
function openThinkingPanel(target: { turnId: string; blockIndex: number }): void {
|
||||
const current = thinkingTarget.value;
|
||||
if (current && current.turnId === target.turnId && current.blockIndex === target.blockIndex) {
|
||||
thinkingTarget.value = null;
|
||||
if (detailTarget.value === 'thinking') detailTarget.value = null;
|
||||
return;
|
||||
}
|
||||
detailTarget.value = 'thinking';
|
||||
thinkingTarget.value = target;
|
||||
}
|
||||
|
||||
function closeThinkingPanel(): void {
|
||||
thinkingTarget.value = null;
|
||||
if (detailTarget.value === 'thinking') detailTarget.value = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compaction summary panel
|
||||
// ---------------------------------------------------------------------------
|
||||
const compactionTarget = ref<{ turnId: string } | null>(null);
|
||||
|
||||
const compactionPanelText = computed<string | null>(() => {
|
||||
const target = compactionTarget.value;
|
||||
if (!target) return null;
|
||||
const turn = client.turns.value.find((tn) => tn.id === target.turnId);
|
||||
return turn?.role === 'compaction' && turn.text ? turn.text : null;
|
||||
});
|
||||
|
||||
const compactionPanelVisible = computed(() => compactionPanelText.value !== null);
|
||||
|
||||
function openCompactionPanel(target: { turnId: string }): void {
|
||||
if (compactionTarget.value?.turnId === target.turnId) {
|
||||
compactionTarget.value = null;
|
||||
if (detailTarget.value === 'compaction') detailTarget.value = null;
|
||||
return;
|
||||
}
|
||||
detailTarget.value = 'compaction';
|
||||
compactionTarget.value = target;
|
||||
}
|
||||
|
||||
function closeCompactionPanel(): void {
|
||||
compactionTarget.value = null;
|
||||
if (detailTarget.value === 'compaction') detailTarget.value = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subagent detail panel
|
||||
// ---------------------------------------------------------------------------
|
||||
const agentTarget = ref<{ turnId: string; blockIndex: number; memberId: string } | null>(null);
|
||||
|
||||
const agentPanelMember = computed<AgentMember | null>(() => {
|
||||
const target = agentTarget.value;
|
||||
if (!target) return null;
|
||||
const turn = client.turns.value.find((tn) => tn.id === target.turnId);
|
||||
const blk = turn?.blocks?.[target.blockIndex];
|
||||
if (!blk) return null;
|
||||
if (blk.kind === 'agent') return blk.member.id === target.memberId ? blk.member : null;
|
||||
if (blk.kind === 'agentGroup') return blk.members.find((m) => m.id === target.memberId) ?? null;
|
||||
return null;
|
||||
});
|
||||
|
||||
const agentPanelVisible = computed(() => agentPanelMember.value !== null);
|
||||
|
||||
function openAgentPanel(target: { turnId: string; blockIndex: number; memberId: string }): void {
|
||||
const current = agentTarget.value;
|
||||
if (current && current.turnId === target.turnId && current.memberId === target.memberId) {
|
||||
agentTarget.value = null;
|
||||
if (detailTarget.value === 'agent') detailTarget.value = null;
|
||||
return;
|
||||
}
|
||||
detailTarget.value = 'agent';
|
||||
agentTarget.value = target;
|
||||
}
|
||||
|
||||
function closeAgentPanel(): void {
|
||||
agentTarget.value = null;
|
||||
if (detailTarget.value === 'agent') detailTarget.value = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Diff detail layer (opened from the chat header git area)
|
||||
// ---------------------------------------------------------------------------
|
||||
const detailDiffMode = ref<'list' | 'detail'>('list');
|
||||
const detailDiffPath = ref<string | null>(null);
|
||||
|
||||
function openDiffDetail(): void {
|
||||
detailTarget.value = 'diff';
|
||||
detailDiffMode.value = 'list';
|
||||
detailDiffPath.value = null;
|
||||
void client.loadGitStatus(client.activeSessionId.value!);
|
||||
}
|
||||
|
||||
function closeDiffDetail(): void {
|
||||
if (detailTarget.value === 'diff') detailTarget.value = null;
|
||||
detailDiffMode.value = 'list';
|
||||
detailDiffPath.value = null;
|
||||
client.clearFileDiff();
|
||||
}
|
||||
|
||||
async function selectDiffFile(path: string): Promise<void> {
|
||||
detailDiffMode.value = 'detail';
|
||||
detailDiffPath.value = path;
|
||||
await client.loadFileDiff(path);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Side chat (BTW) — now rendered in the unified right-side detail layer.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function openSideChatTab(prompt?: string): Promise<void> {
|
||||
await client.openSideChat(prompt);
|
||||
detailTarget.value = 'btw';
|
||||
}
|
||||
|
||||
function closeSideChat(): void {
|
||||
client.closeSideChat();
|
||||
if (detailTarget.value === 'btw') detailTarget.value = null;
|
||||
}
|
||||
|
||||
// Only hides the right-side BTW panel; the side-chat target is per-session and
|
||||
// preserved so switching back to a session restores its BTW transcript.
|
||||
function hideSideChatPanel(): void {
|
||||
if (detailTarget.value === 'btw') detailTarget.value = null;
|
||||
}
|
||||
|
||||
const btwVisible = computed(() => client.sideChatVisible.value);
|
||||
|
||||
/** Any occupant of the shared right-side slot. */
|
||||
const sidePanelVisible = computed(
|
||||
() =>
|
||||
detailTarget.value !== null &&
|
||||
(detailTarget.value !== 'thinking' || thinkingVisible.value) &&
|
||||
(detailTarget.value !== 'compaction' || compactionPanelVisible.value) &&
|
||||
(detailTarget.value !== 'agent' || agentPanelVisible.value) &&
|
||||
(detailTarget.value !== 'btw' || btwVisible.value),
|
||||
);
|
||||
|
||||
/** True while the panel's resize handle is being dragged — the width
|
||||
transition is disabled so the panel follows the pointer 1:1. */
|
||||
const panelDragging = ref(false);
|
||||
|
||||
function openPreviewInEditor(): void {
|
||||
const path = previewFile.value?.path ?? previewTarget.value?.path;
|
||||
if (!path) return;
|
||||
void client.openWorkspaceFile(path, previewTarget.value?.line);
|
||||
}
|
||||
|
||||
function revealPreviewFile(): void {
|
||||
const path = previewFile.value?.path ?? previewTarget.value?.path;
|
||||
if (!path) return;
|
||||
void client.revealWorkspaceFile(path);
|
||||
}
|
||||
|
||||
watch(client.activeSessionId, () => {
|
||||
closeFilePreview();
|
||||
closeThinkingPanel();
|
||||
closeCompactionPanel();
|
||||
closeAgentPanel();
|
||||
closeDiffDetail();
|
||||
hideSideChatPanel();
|
||||
});
|
||||
const {
|
||||
PREVIEW_WIDTH_KEY,
|
||||
PREVIEW_MIN,
|
||||
previewDefaultWidth,
|
||||
previewMaxWidth,
|
||||
previewWidth,
|
||||
thinkingPanelText,
|
||||
thinkingVisible,
|
||||
openThinkingPanel,
|
||||
closeThinkingPanel,
|
||||
compactionPanelText,
|
||||
compactionPanelVisible,
|
||||
openCompactionPanel,
|
||||
closeCompactionPanel,
|
||||
agentPanelMember,
|
||||
openAgentPanel,
|
||||
closeAgentPanel,
|
||||
detailDiffMode,
|
||||
detailDiffPath,
|
||||
openDiffDetail,
|
||||
closeDiffDetail,
|
||||
selectDiffFile,
|
||||
btwVisible,
|
||||
openSideChatTab,
|
||||
closeSideChat,
|
||||
sidePanelVisible,
|
||||
panelDragging,
|
||||
closeOpenSidePanel,
|
||||
} = useDetailPanel({ client, sideWidth, detailTarget, closeFilePreview });
|
||||
|
||||
// Reference to ConversationPane so we can imperatively switch tabs
|
||||
const conversationPaneRef = ref<InstanceType<typeof ConversationPane> | null>(null);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
import { computed, inject, nextTick, provide, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Markdown from './Markdown.vue';
|
||||
import type { FilePreviewRequest } from '../types';
|
||||
import type { FileData, FilePreviewRequest } from '../types';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
|
|
@ -62,18 +62,6 @@ function resolveMarkdownFileTarget(target: { path: string; line?: number }): Fil
|
|||
return { ...target, path: resolveRelativePath(href, base) };
|
||||
}
|
||||
|
||||
export interface FileData {
|
||||
path: string;
|
||||
content: string;
|
||||
encoding: 'utf-8' | 'base64';
|
||||
mime: string;
|
||||
sourceUrl?: string;
|
||||
languageId?: string;
|
||||
isBinary: boolean;
|
||||
size: number;
|
||||
lineCount?: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
file: FileData | null;
|
||||
loading: boolean;
|
||||
|
|
|
|||
385
apps/kimi-web/src/composables/client/useModelProviderState.ts
Normal file
385
apps/kimi-web/src/composables/client/useModelProviderState.ts
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
// apps/kimi-web/src/composables/client/useModelProviderState.ts
|
||||
// Models, providers, starred/favorite models, the active-session thinking
|
||||
// level, session-scoped slash skills, and the managed OAuth device flow.
|
||||
// Owns the lazy-loaded model/provider caches plus the new-session "draft"
|
||||
// model pick. Cross-dependencies (failure reporting, status refresh, activity,
|
||||
// in-flight set, thinking storage) are injected by the facade.
|
||||
|
||||
import { ref, type ComputedRef } from 'vue';
|
||||
import { getKimiWebApi } from '../../api';
|
||||
import type { AppMessage, AppModel, AppProvider, AppSession, AppSkill, ThinkingLevel } from '../../api/types';
|
||||
import { safeGetString, safeSetString, STORAGE_KEYS } from '../../lib/storage';
|
||||
import { coerceThinkingForModel } from '../../lib/modelThinking';
|
||||
import type { ActivityState } from '../../types';
|
||||
import type { ExtendedState } from '../useKimiWebClient';
|
||||
|
||||
const STARRED_MODELS_STORAGE_KEY = STORAGE_KEYS.starredModels;
|
||||
|
||||
function loadStarredModelsFromStorage(): string[] {
|
||||
try {
|
||||
const raw = safeGetString(STARRED_MODELS_STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed) && parsed.every((item) => typeof item === 'string')) {
|
||||
return parsed as string[];
|
||||
}
|
||||
} catch {
|
||||
// ignore (localStorage not available or malformed)
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function saveStarredModelsToStorage(v: string[]): void {
|
||||
try {
|
||||
safeSetString(STARRED_MODELS_STORAGE_KEY, JSON.stringify(v));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export interface PersistSessionProfilePatch {
|
||||
model?: string;
|
||||
permissionMode?: string;
|
||||
planMode?: boolean;
|
||||
swarmMode?: boolean;
|
||||
goalObjective?: string;
|
||||
goalControl?: 'pause' | 'resume' | 'cancel';
|
||||
thinking?: string;
|
||||
}
|
||||
|
||||
export interface UseModelProviderStateDeps {
|
||||
pushOperationFailure: (
|
||||
operation: string,
|
||||
err: unknown,
|
||||
opts?: { title?: string; message?: string; sessionId?: string },
|
||||
) => void;
|
||||
refreshSessionStatus: (sessionId: string) => Promise<void>;
|
||||
persistSessionProfile: (patch: PersistSessionProfilePatch) => void;
|
||||
activity: ComputedRef<ActivityState>;
|
||||
inFlightPromptSessions: Set<string>;
|
||||
saveThinkingToStorage: (v: ThinkingLevel) => void;
|
||||
/** Replace one session in place (matched by id). Owned by the facade so the
|
||||
* model module never assigns rawState.sessions directly. */
|
||||
updateSession: (id: string, update: (session: AppSession) => AppSession) => void;
|
||||
/** Update one session's message list via a function of the current list. */
|
||||
updateSessionMessages: (
|
||||
sessionId: string,
|
||||
update: (messages: AppMessage[]) => AppMessage[],
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function useModelProviderState(
|
||||
rawState: ExtendedState,
|
||||
deps: UseModelProviderStateDeps,
|
||||
) {
|
||||
const {
|
||||
pushOperationFailure,
|
||||
refreshSessionStatus,
|
||||
persistSessionProfile,
|
||||
activity,
|
||||
inFlightPromptSessions,
|
||||
saveThinkingToStorage,
|
||||
updateSession,
|
||||
updateSessionMessages,
|
||||
} = deps;
|
||||
|
||||
// Models + Providers reactive state (lazy-loaded, cached)
|
||||
const models = ref<AppModel[]>([]);
|
||||
const starredModelIds = ref<string[]>(loadStarredModelsFromStorage());
|
||||
|
||||
// Session-scoped skills (slash-invocable). Loaded lazily per session; the active
|
||||
// session's list feeds the composer's `/` menu.
|
||||
const skillsBySession = ref<Record<string, AppSkill[]>>({});
|
||||
const providers = ref<AppProvider[]>([]);
|
||||
|
||||
// Model picked while in the "new session draft" state (onboarding composer —
|
||||
// no backend session exists yet, so POST /profile has nothing to target).
|
||||
// Applied and cleared when the first prompt creates the session.
|
||||
const draftModel = ref<string | null>(null);
|
||||
|
||||
function modelById(modelId: string | null | undefined): AppModel | undefined {
|
||||
if (modelId === undefined || modelId === null || modelId.length === 0) return undefined;
|
||||
return models.value.find((m) => m.id === modelId || m.model === modelId);
|
||||
}
|
||||
|
||||
function activeThinkingModel(): AppModel | undefined {
|
||||
const activeSession = rawState.activeSessionId
|
||||
? rawState.sessions.find((s) => s.id === rawState.activeSessionId)
|
||||
: undefined;
|
||||
return modelById(activeSession?.model ?? draftModel.value ?? rawState.defaultModel);
|
||||
}
|
||||
|
||||
function applyThinkingLevel(level: ThinkingLevel): ThinkingLevel {
|
||||
const next = coerceThinkingForModel(activeThinkingModel(), level);
|
||||
rawState.thinking = next;
|
||||
saveThinkingToStorage(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
async function loadSkillsForSession(sessionId: string): Promise<void> {
|
||||
try {
|
||||
const api = getKimiWebApi();
|
||||
const list = await api.listSkills(sessionId);
|
||||
skillsBySession.value = { ...skillsBySession.value, [sessionId]: list };
|
||||
} catch {
|
||||
// Skills are side data; an older daemon without /skills just yields no
|
||||
// slash-skills, the built-in commands still work.
|
||||
}
|
||||
}
|
||||
|
||||
/** Load models (cached — call again to force refresh) */
|
||||
async function loadModels(): Promise<void> {
|
||||
try {
|
||||
const api = getKimiWebApi();
|
||||
models.value = await api.listModels();
|
||||
applyThinkingLevel(rawState.thinking);
|
||||
} catch (err) {
|
||||
pushOperationFailure('loadModels', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshOAuthProviderModels(): Promise<void> {
|
||||
try {
|
||||
const result = await getKimiWebApi().refreshOAuthProviderModels();
|
||||
for (const failure of result.failed) {
|
||||
pushOperationFailure('refreshOAuthProviderModels', new Error(failure.reason), {
|
||||
message: failure.provider,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Older daemons may not expose this endpoint; model listing still works.
|
||||
}
|
||||
}
|
||||
|
||||
/** Load providers */
|
||||
async function loadProviders(): Promise<void> {
|
||||
try {
|
||||
const api = getKimiWebApi();
|
||||
providers.value = await api.listProviders();
|
||||
} catch (err) {
|
||||
pushOperationFailure('loadProviders', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch model for the active session via POST /sessions/{id}/profile (the
|
||||
* daemon dispatches agent_config.model to core.rpc.setModel). The profile echo
|
||||
* can return model '', so the authoritative current model comes from
|
||||
* GET /sessions/{id}/status, which we re-read right after. Optimistically show
|
||||
* the chosen id meanwhile. Never crashes.
|
||||
*/
|
||||
async function setModel(modelId: string): Promise<void> {
|
||||
const sid = rawState.activeSessionId;
|
||||
const nextThinking = coerceThinkingForModel(modelById(modelId), rawState.thinking);
|
||||
const prevThinking = rawState.thinking;
|
||||
if (!sid) {
|
||||
// New-session draft (onboarding composer): no backend session to update.
|
||||
// Remember the pick — startSessionAndSendPrompt applies it at create time.
|
||||
draftModel.value = modelId;
|
||||
applyThinkingLevel(nextThinking);
|
||||
return;
|
||||
}
|
||||
// Optimistic: show the chosen model immediately, but remember the previous
|
||||
// one so we can roll back if the switch never reaches the daemon.
|
||||
const prevModel = rawState.sessions.find((s) => s.id === sid)?.model;
|
||||
updateSession(sid, (s) => ({ ...s, model: modelId }));
|
||||
if (nextThinking !== prevThinking) {
|
||||
rawState.thinking = nextThinking;
|
||||
saveThinkingToStorage(nextThinking);
|
||||
}
|
||||
try {
|
||||
await getKimiWebApi().updateSession(sid, {
|
||||
model: modelId,
|
||||
thinking: nextThinking !== prevThinking ? nextThinking : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
// The model change rides HTTP, not the WS, so a dropped socket alone does
|
||||
// not fail it — but when the daemon is unreachable the request throws here.
|
||||
// Roll the picker back to the real model so the UI can't keep showing the
|
||||
// new one as if the switch succeeded, then surface the failure.
|
||||
updateSession(sid, (s) => ({ ...s, model: prevModel ?? s.model }));
|
||||
if (nextThinking !== prevThinking) {
|
||||
rawState.thinking = prevThinking;
|
||||
saveThinkingToStorage(prevThinking);
|
||||
}
|
||||
pushOperationFailure('setModel', err, { sessionId: sid });
|
||||
return;
|
||||
}
|
||||
// refreshSessionStatus folds the authoritative current model from /status
|
||||
// back into the session (the profile echo can return ''). Best-effort: a
|
||||
// failure here does not mean the switch failed, so it must not roll back.
|
||||
await refreshSessionStatus(sid);
|
||||
}
|
||||
|
||||
/** Toggle whether a model is starred (favorited) in the model picker. */
|
||||
function toggleStarModel(modelId: string): void {
|
||||
const set = new Set(starredModelIds.value);
|
||||
if (set.has(modelId)) {
|
||||
set.delete(modelId);
|
||||
} else {
|
||||
set.add(modelId);
|
||||
}
|
||||
starredModelIds.value = Array.from(set);
|
||||
saveStarredModelsToStorage(starredModelIds.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a session skill (the web analogue of typing `/<skill> <args>` in the
|
||||
* TUI). The daemon starts a turn with a `skill_activation` origin; progress
|
||||
* arrives over the WS stream like any other turn. Never crashes the caller.
|
||||
*/
|
||||
async function activateSkill(skillName: string, args?: string): Promise<void> {
|
||||
const sid = rawState.activeSessionId;
|
||||
if (!sid) return;
|
||||
const guarded = activity.value === 'idle' && !inFlightPromptSessions.has(sid);
|
||||
const tempId = `msg_skill_opt_${Date.now().toString(36)}`;
|
||||
|
||||
if (guarded) {
|
||||
inFlightPromptSessions.add(sid);
|
||||
rawState.sendingBySession = { ...rawState.sendingBySession, [sid]: true };
|
||||
const optimisticMsg: AppMessage = {
|
||||
id: tempId,
|
||||
sessionId: sid,
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: `/${skillName}${args ? ` ${args}` : ''}` }],
|
||||
createdAt: new Date().toISOString(),
|
||||
metadata: {
|
||||
'kimiWeb.optimisticUserMessage': true,
|
||||
origin: {
|
||||
kind: 'skill_activation',
|
||||
trigger: 'user-slash',
|
||||
skillName,
|
||||
skillArgs: args,
|
||||
},
|
||||
},
|
||||
};
|
||||
updateSessionMessages(sid, (msgs) => [...msgs, optimisticMsg]);
|
||||
}
|
||||
|
||||
try {
|
||||
await getKimiWebApi().activateSkill(sid, skillName, args);
|
||||
} catch (err) {
|
||||
if (guarded) {
|
||||
inFlightPromptSessions.delete(sid);
|
||||
rawState.sendingBySession = { ...rawState.sendingBySession, [sid]: false };
|
||||
updateSessionMessages(sid, (msgs) => msgs.filter((m) => m.id !== tempId));
|
||||
}
|
||||
pushOperationFailure('activateSkill', err, { sessionId: sid });
|
||||
}
|
||||
}
|
||||
|
||||
/** Add a provider, then reload providers + models */
|
||||
async function addProvider(input: {
|
||||
type: string;
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
defaultModel?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const api = getKimiWebApi();
|
||||
await api.addProvider(input);
|
||||
await Promise.all([loadProviders(), loadModels()]);
|
||||
} catch (err) {
|
||||
pushOperationFailure('addProvider', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete a provider, then reload providers + models */
|
||||
async function deleteProvider(id: string): Promise<void> {
|
||||
try {
|
||||
const api = getKimiWebApi();
|
||||
await api.deleteProvider(id);
|
||||
await Promise.all([loadProviders(), loadModels()]);
|
||||
} catch (err) {
|
||||
pushOperationFailure('deleteProvider', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Refresh a provider status */
|
||||
async function refreshProvider(id: string): Promise<void> {
|
||||
try {
|
||||
const api = getKimiWebApi();
|
||||
const updated = await api.refreshProvider(id);
|
||||
providers.value = providers.value.map((p) => (p.id === id ? updated : p));
|
||||
} catch (err) {
|
||||
pushOperationFailure('refreshProvider', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Start managed Kimi OAuth device flow. Returns flow data or null on error. */
|
||||
async function startOAuthLogin(): Promise<{
|
||||
flowId: string;
|
||||
provider: string;
|
||||
verificationUri: string;
|
||||
verificationUriComplete: string;
|
||||
userCode: string;
|
||||
expiresIn: number;
|
||||
interval: number;
|
||||
status: 'pending';
|
||||
expiresAt: string;
|
||||
} | null> {
|
||||
try {
|
||||
const api = getKimiWebApi();
|
||||
return await api.startOAuthLogin();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Poll the singleton OAuth flow. Returns null on error or no active flow. */
|
||||
async function pollOAuthLogin(): Promise<{
|
||||
flowId: string;
|
||||
status: 'pending' | 'authenticated' | 'expired' | 'cancelled';
|
||||
resolvedAt?: string;
|
||||
} | null> {
|
||||
try {
|
||||
const api = getKimiWebApi();
|
||||
return await api.pollOAuthLogin();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancel the current OAuth flow (best-effort). */
|
||||
async function cancelOAuthLogin(): Promise<void> {
|
||||
try {
|
||||
const api = getKimiWebApi();
|
||||
await api.cancelOAuthLogin();
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist and apply a new extended-thinking level (also pushed to the active
|
||||
* session profile so the daemon's /status reflects it; still sent per-prompt). */
|
||||
function setThinking(level: ThinkingLevel): void {
|
||||
const next = applyThinkingLevel(level);
|
||||
persistSessionProfile({ thinking: next });
|
||||
}
|
||||
|
||||
return {
|
||||
// state
|
||||
models,
|
||||
starredModelIds,
|
||||
providers,
|
||||
draftModel,
|
||||
skillsBySession,
|
||||
// actions
|
||||
loadSkillsForSession,
|
||||
loadModels,
|
||||
refreshOAuthProviderModels,
|
||||
loadProviders,
|
||||
setModel,
|
||||
toggleStarModel,
|
||||
activateSkill,
|
||||
addProvider,
|
||||
deleteProvider,
|
||||
refreshProvider,
|
||||
startOAuthLogin,
|
||||
pollOAuthLogin,
|
||||
cancelOAuthLogin,
|
||||
setThinking,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseModelProviderState = ReturnType<typeof useModelProviderState>;
|
||||
242
apps/kimi-web/src/composables/client/useSideChat.ts
Normal file
242
apps/kimi-web/src/composables/client/useSideChat.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
// apps/kimi-web/src/composables/client/useSideChat.ts
|
||||
// Side chat ("BTW") — a TUI-style forked agent rendered as a session tab.
|
||||
// It is not a child session and never appears in the sidebar. Each session can
|
||||
// have its own side chat; state is keyed by session id, while messages are
|
||||
// keyed by agent id so they survive session switches.
|
||||
//
|
||||
// Cross-dependencies (failure reporting, optimistic-id generation, the event
|
||||
// connection) are injected by the facade.
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
import { getKimiWebApi } from '../../api';
|
||||
import type { AppMessage } from '../../api/types';
|
||||
import type { KimiEventConnection } from '../../api/types';
|
||||
import { messagesToTurns } from '../messagesToTurns';
|
||||
import type { ChatTurn } from '../../types';
|
||||
import type { ExtendedState } from '../useKimiWebClient';
|
||||
|
||||
export interface UseSideChatDeps {
|
||||
pushOperationFailure: (
|
||||
operation: string,
|
||||
err: unknown,
|
||||
opts?: { title?: string; message?: string; sessionId?: string },
|
||||
) => void;
|
||||
nextOptimisticMsgId: () => string;
|
||||
connectEventsIfNeeded: () => void;
|
||||
getEventConn: () => KimiEventConnection | null;
|
||||
}
|
||||
|
||||
export function useSideChat(rawState: ExtendedState, deps: UseSideChatDeps) {
|
||||
const { pushOperationFailure, nextOptimisticMsgId, connectEventsIfNeeded, getEventConn } = deps;
|
||||
|
||||
const sideChatTargetBySession = ref<Record<string, { agentId: string }>>({});
|
||||
|
||||
const activeSideChatTarget = computed<{ parentId: string; agentId: string } | null>(() => {
|
||||
const sid = rawState.activeSessionId;
|
||||
if (!sid) return null;
|
||||
const target = sideChatTargetBySession.value[sid];
|
||||
return target ? { parentId: sid, agentId: target.agentId } : null;
|
||||
});
|
||||
|
||||
const sideChatSessionId = computed<string | null>(
|
||||
() => activeSideChatTarget.value?.parentId ?? null,
|
||||
);
|
||||
const sideChatVisible = computed<boolean>(() => activeSideChatTarget.value !== null);
|
||||
|
||||
const sideChatSending = computed<boolean>(() => {
|
||||
const target = activeSideChatTarget.value;
|
||||
return target ? Boolean(rawState.sideChatSendingByAgent[target.agentId]) : false;
|
||||
});
|
||||
|
||||
const sideChatRunning = computed<boolean>(() => {
|
||||
const target = activeSideChatTarget.value;
|
||||
if (!target) return false;
|
||||
if (rawState.sideChatSendingByAgent[target.agentId]) return true;
|
||||
return (rawState.tasksBySession[target.parentId] ?? []).some(
|
||||
(task) => task.id === target.agentId && task.status === 'running',
|
||||
);
|
||||
});
|
||||
|
||||
const sideChatTurns = computed<ChatTurn[]>(() => {
|
||||
const target = activeSideChatTarget.value;
|
||||
if (!target) return [];
|
||||
const messages = rawState.sideChatMessagesByAgent[target.agentId] ?? [];
|
||||
return messagesToTurns(
|
||||
messages,
|
||||
[],
|
||||
(fileId) => getKimiWebApi().getFileUrl(fileId),
|
||||
sideChatRunning.value,
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
function updateSideChatMessages(agentId: string, update: (messages: AppMessage[]) => AppMessage[]): void {
|
||||
rawState.sideChatMessagesByAgent = {
|
||||
...rawState.sideChatMessagesByAgent,
|
||||
[agentId]: update(rawState.sideChatMessagesByAgent[agentId] ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
function appendSideChatMessage(agentId: string, message: AppMessage): void {
|
||||
updateSideChatMessages(agentId, (messages) => [...messages, message]);
|
||||
}
|
||||
|
||||
function removeLastSideChatUserMessage(agentId: string): void {
|
||||
updateSideChatMessages(agentId, (messages) => {
|
||||
const idx = [...messages].reverse().findIndex((message) => message.role === 'user');
|
||||
if (idx === -1) return messages;
|
||||
const removeIndex = messages.length - 1 - idx;
|
||||
return messages.filter((_, index) => index !== removeIndex);
|
||||
});
|
||||
}
|
||||
|
||||
function stampLastSideChatUserPrompt(agentId: string, promptId: string): void {
|
||||
updateSideChatMessages(agentId, (messages) => {
|
||||
const next = [...messages];
|
||||
for (let i = next.length - 1; i >= 0; i -= 1) {
|
||||
const message = next[i]!;
|
||||
if (message.role !== 'user') continue;
|
||||
next[i] = { ...message, promptId: message.promptId ?? promptId };
|
||||
return next;
|
||||
}
|
||||
return messages;
|
||||
});
|
||||
}
|
||||
|
||||
function appendSideChatAssistantText(agentId: string, sessionId: string, chunk: string): void {
|
||||
if (!chunk) return;
|
||||
updateSideChatMessages(agentId, (messages) => {
|
||||
const last = messages.at(-1);
|
||||
if (last?.role === 'assistant') {
|
||||
const first = last.content[0];
|
||||
const text = first?.type === 'text' ? first.text : '';
|
||||
return [
|
||||
...messages.slice(0, -1),
|
||||
{
|
||||
...last,
|
||||
content: [{ type: 'text', text: `${text}${chunk}` }],
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
...messages,
|
||||
{
|
||||
id: nextOptimisticMsgId(),
|
||||
sessionId,
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: chunk }],
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function finishSideChatAgent(agentId: string, sessionId: string, outputPreview?: string): void {
|
||||
rawState.sideChatSendingByAgent = { ...rawState.sideChatSendingByAgent, [agentId]: false };
|
||||
if (!outputPreview) return;
|
||||
const messages = rawState.sideChatMessagesByAgent[agentId] ?? [];
|
||||
const last = messages.at(-1);
|
||||
const lastText = last?.role === 'assistant' && last.content[0]?.type === 'text'
|
||||
? last.content[0].text
|
||||
: '';
|
||||
if (lastText.trim().length > 0) return;
|
||||
appendSideChatAssistantText(agentId, sessionId, outputPreview);
|
||||
}
|
||||
|
||||
/** Open (creating if needed) the side chat for the active session; optionally send a first prompt. */
|
||||
async function openSideChat(initialPrompt?: string): Promise<void> {
|
||||
const parent = rawState.activeSessionId;
|
||||
if (!parent) return;
|
||||
// Reuse the existing side chat for this session if it already exists.
|
||||
if (!sideChatTargetBySession.value[parent]) {
|
||||
let agentId: string;
|
||||
try {
|
||||
({ agentId } = await getKimiWebApi().startBtw(parent));
|
||||
} catch (err) {
|
||||
pushOperationFailure('openSideChat', err, { sessionId: parent });
|
||||
return;
|
||||
}
|
||||
rawState.sideChatMessagesByAgent = {
|
||||
...rawState.sideChatMessagesByAgent,
|
||||
[agentId]: rawState.sideChatMessagesByAgent[agentId] ?? [],
|
||||
};
|
||||
sideChatTargetBySession.value = {
|
||||
...sideChatTargetBySession.value,
|
||||
[parent]: { agentId },
|
||||
};
|
||||
connectEventsIfNeeded();
|
||||
getEventConn()?.markSideChannelAgent(agentId);
|
||||
}
|
||||
if (initialPrompt && initialPrompt.trim()) {
|
||||
await sendSideChatPrompt(initialPrompt.trim());
|
||||
}
|
||||
}
|
||||
|
||||
function closeSideChat(): void {
|
||||
const sid = rawState.activeSessionId;
|
||||
if (!sid) return;
|
||||
const { [sid]: _removed, ...rest } = sideChatTargetBySession.value;
|
||||
void _removed;
|
||||
sideChatTargetBySession.value = rest;
|
||||
}
|
||||
|
||||
/** Send a plain prompt to the side-chat child (no plan/swarm/goal modes). */
|
||||
async function sendSideChatPrompt(text: string): Promise<void> {
|
||||
const target = activeSideChatTarget.value;
|
||||
const trimmed = text.trim();
|
||||
if (!target || !trimmed) return;
|
||||
const sid = target.parentId;
|
||||
const agentId = target.agentId;
|
||||
rawState.sideChatSendingByAgent = { ...rawState.sideChatSendingByAgent, [agentId]: true };
|
||||
const userMsg: AppMessage = {
|
||||
id: nextOptimisticMsgId(),
|
||||
sessionId: sid,
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: trimmed }],
|
||||
createdAt: new Date().toISOString(),
|
||||
metadata: { 'kimiWeb.optimisticUserMessage': true },
|
||||
};
|
||||
appendSideChatMessage(agentId, userMsg);
|
||||
try {
|
||||
const result = await getKimiWebApi().submitPrompt(sid, {
|
||||
content: [{ type: 'text', text: trimmed }],
|
||||
agentId,
|
||||
});
|
||||
stampLastSideChatUserPrompt(agentId, result.promptId);
|
||||
rawState.sideChatUserMessageIdsBySession = {
|
||||
...rawState.sideChatUserMessageIdsBySession,
|
||||
[sid]: [...(rawState.sideChatUserMessageIdsBySession[sid] ?? []), result.userMessageId],
|
||||
};
|
||||
} catch (err) {
|
||||
pushOperationFailure('sendSideChatPrompt', err, { sessionId: sid });
|
||||
removeLastSideChatUserMessage(agentId);
|
||||
rawState.sideChatSendingByAgent = { ...rawState.sideChatSendingByAgent, [agentId]: false };
|
||||
}
|
||||
}
|
||||
|
||||
// When a session is deleted, drop its side-chat target so it cannot leak into a
|
||||
// later session that happens to reuse the same id.
|
||||
function clearSideChatForSession(sessionId: string): void {
|
||||
if (!sideChatTargetBySession.value[sessionId]) return;
|
||||
const { [sessionId]: _removed, ...rest } = sideChatTargetBySession.value;
|
||||
void _removed;
|
||||
sideChatTargetBySession.value = rest;
|
||||
}
|
||||
|
||||
return {
|
||||
sideChatTargetBySession,
|
||||
sideChatSessionId,
|
||||
sideChatVisible,
|
||||
sideChatSending,
|
||||
sideChatRunning,
|
||||
sideChatTurns,
|
||||
appendSideChatAssistantText,
|
||||
finishSideChatAgent,
|
||||
openSideChat,
|
||||
closeSideChat,
|
||||
sendSideChatPrompt,
|
||||
clearSideChatForSession,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseSideChat = ReturnType<typeof useSideChat>;
|
||||
264
apps/kimi-web/src/composables/client/useTaskPoller.ts
Normal file
264
apps/kimi-web/src/composables/client/useTaskPoller.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
// apps/kimi-web/src/composables/client/useTaskPoller.ts
|
||||
// Background task output polling and the 1-second task clock used to keep
|
||||
// running-task elapsed timers live in the UI.
|
||||
|
||||
import { computed, ref, watch, type ComputedRef, type Ref } from 'vue';
|
||||
import { getKimiWebApi } from '../../api';
|
||||
import type { AppTask } from '../../api/types';
|
||||
import { keepLiveSubagents } from '../../lib/taskMerge';
|
||||
import type { ExtendedState } from '../useKimiWebClient';
|
||||
|
||||
const TASK_OUTPUT_POLL_INTERVAL_MS = 1000;
|
||||
const TASK_OUTPUT_POLL_BYTES = 4096;
|
||||
const TASK_OUTPUT_FINAL_BYTES = 32 * 1024;
|
||||
|
||||
export interface UseTaskPoller {
|
||||
/** 1-second clock that ticks while an active app task is running. */
|
||||
taskClock: Readonly<Ref<number>>;
|
||||
/** One-off load of the task list for a session, plus terminal-output backfill. */
|
||||
loadTasksForSession: (sessionId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useTaskPoller(
|
||||
rawState: ExtendedState,
|
||||
activeAppTasks: ComputedRef<AppTask[]>,
|
||||
): UseTaskPoller {
|
||||
let taskOutputPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let lastPolledSessionId: string | undefined;
|
||||
const fetchedTerminalTaskOutputIds = new Set<string>();
|
||||
|
||||
async function loadTasksForSession(sessionId: string): Promise<void> {
|
||||
try {
|
||||
const api = getKimiWebApi();
|
||||
const taskList = await api.listTasks(sessionId);
|
||||
rawState.tasksBySession = {
|
||||
...rawState.tasksBySession,
|
||||
// Keep WS-delivered swarm subagents that REST /tasks omits (see keepLiveSubagents).
|
||||
[sessionId]: keepLiveSubagents(taskList, rawState.tasksBySession[sessionId] ?? []),
|
||||
};
|
||||
// Completed tasks may have real terminal output that never streamed over
|
||||
// WS. Fetch it once now so the rows are expandable when the session opens.
|
||||
await fetchTerminalTaskOutputs(sessionId, taskList);
|
||||
} catch {
|
||||
// Tasks are side data; old/stale sessions may fail without blocking messages.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the final output snapshot for terminal tasks that lack real streamed
|
||||
* outputLines. Called once after loading the task list so already-completed
|
||||
* tasks are clickable immediately.
|
||||
*/
|
||||
async function fetchTerminalTaskOutputs(
|
||||
sessionId: string,
|
||||
taskList?: AppTask[],
|
||||
): Promise<void> {
|
||||
if (rawState.activeSessionId !== sessionId) return;
|
||||
|
||||
const tasks = taskList ?? rawState.tasksBySession[sessionId] ?? [];
|
||||
const api = getKimiWebApi();
|
||||
const outputByTaskId = new Map<string, { preview: string; bytes?: number }>();
|
||||
|
||||
await Promise.all(
|
||||
tasks.map(async (task) => {
|
||||
const isTerminal =
|
||||
task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled';
|
||||
if (!isTerminal) return;
|
||||
if (fetchedTerminalTaskOutputIds.has(task.id)) return;
|
||||
if ((task.outputLines?.length ?? 0) > 0) return;
|
||||
|
||||
try {
|
||||
const withOutput = await api.getTask(sessionId, task.id, {
|
||||
withOutput: true,
|
||||
outputBytes: TASK_OUTPUT_FINAL_BYTES,
|
||||
});
|
||||
if (withOutput.outputPreview !== undefined) {
|
||||
outputByTaskId.set(task.id, {
|
||||
preview: withOutput.outputPreview,
|
||||
bytes: withOutput.outputBytes,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Task may have finished between listTasks and getTask; ignore.
|
||||
} finally {
|
||||
fetchedTerminalTaskOutputIds.add(task.id);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (outputByTaskId.size === 0) return;
|
||||
|
||||
const existing = rawState.tasksBySession[sessionId] ?? [];
|
||||
rawState.tasksBySession = {
|
||||
...rawState.tasksBySession,
|
||||
[sessionId]: existing.map((t) => {
|
||||
const polled = outputByTaskId.get(t.id);
|
||||
if (!polled) return t;
|
||||
return { ...t, outputPreview: polled.preview, outputBytes: polled.bytes };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll background task output for a session. Mirrors the TUI's 1-second refresh:
|
||||
* refresh the task list, then fetch tail output for running tasks and a final
|
||||
* snapshot for terminal tasks that haven't received output yet.
|
||||
*/
|
||||
async function pollTaskOutputForSession(sessionId: string): Promise<void> {
|
||||
if (rawState.activeSessionId !== sessionId) return;
|
||||
|
||||
const api = getKimiWebApi();
|
||||
let taskList: AppTask[];
|
||||
try {
|
||||
taskList = await api.listTasks(sessionId);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const outputByTaskId = new Map<string, { preview: string; bytes?: number }>();
|
||||
|
||||
await Promise.all(
|
||||
taskList.map(async (task) => {
|
||||
const isRunning = task.status === 'running';
|
||||
const isTerminal =
|
||||
task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled';
|
||||
if (!isRunning && !isTerminal) return;
|
||||
|
||||
// Running tasks: poll tail continuously. Terminal tasks: fetch a final
|
||||
// snapshot once if we have not already received real streamed output.
|
||||
// outputPreview may be a placeholder (`$ <command>`) or a partial tail,
|
||||
// so we intentionally do not skip terminal tasks just because outputPreview
|
||||
// is present.
|
||||
if (isTerminal) {
|
||||
if (fetchedTerminalTaskOutputIds.has(task.id)) return;
|
||||
if ((task.outputLines?.length ?? 0) > 0) return;
|
||||
}
|
||||
|
||||
try {
|
||||
const withOutput = await api.getTask(sessionId, task.id, {
|
||||
withOutput: true,
|
||||
outputBytes: isRunning ? TASK_OUTPUT_POLL_BYTES : TASK_OUTPUT_FINAL_BYTES,
|
||||
});
|
||||
if (withOutput.outputPreview !== undefined) {
|
||||
outputByTaskId.set(task.id, {
|
||||
preview: withOutput.outputPreview,
|
||||
bytes: withOutput.outputBytes,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Task may have finished between listTasks and getTask; ignore.
|
||||
} finally {
|
||||
if (isTerminal) {
|
||||
fetchedTerminalTaskOutputIds.add(task.id);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const existing = rawState.tasksBySession[sessionId] ?? [];
|
||||
const existingById = new Map(existing.map((t) => [t.id, t] as const));
|
||||
|
||||
const refreshed: AppTask[] = taskList.map((fresh) => {
|
||||
const old = existingById.get(fresh.id);
|
||||
const polled = outputByTaskId.get(fresh.id);
|
||||
return {
|
||||
...fresh,
|
||||
// Preserve any WS-driven outputLines (future taskProgress events).
|
||||
outputLines: old?.outputLines,
|
||||
outputPreview: polled?.preview ?? old?.outputPreview,
|
||||
outputBytes: polled?.bytes ?? old?.outputBytes,
|
||||
};
|
||||
});
|
||||
|
||||
rawState.tasksBySession = {
|
||||
...rawState.tasksBySession,
|
||||
// Keep WS-delivered swarm subagents that REST /tasks omits (see keepLiveSubagents).
|
||||
[sessionId]: keepLiveSubagents(refreshed, existing),
|
||||
};
|
||||
}
|
||||
|
||||
function startTaskOutputPolling(sessionId: string): void {
|
||||
if (taskOutputPollTimer !== null && lastPolledSessionId === sessionId) {
|
||||
return;
|
||||
}
|
||||
stopTaskOutputPolling();
|
||||
lastPolledSessionId = sessionId;
|
||||
void pollTaskOutputForSession(sessionId);
|
||||
taskOutputPollTimer = setInterval(() => {
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
|
||||
return;
|
||||
}
|
||||
if (rawState.activeSessionId === sessionId) {
|
||||
void pollTaskOutputForSession(sessionId);
|
||||
} else {
|
||||
stopTaskOutputPolling();
|
||||
}
|
||||
}, TASK_OUTPUT_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function stopTaskOutputPolling(): void {
|
||||
if (taskOutputPollTimer !== null) {
|
||||
clearInterval(taskOutputPollTimer);
|
||||
taskOutputPollTimer = null;
|
||||
}
|
||||
lastPolledSessionId = undefined;
|
||||
fetchedTerminalTaskOutputIds.clear();
|
||||
}
|
||||
|
||||
// A 1-second clock that only ticks while a task is running, so a running task's
|
||||
// elapsed-time label keeps counting up. UI task mappers read Date.now() once per
|
||||
// evaluation; without this the `tasks` computed only re-ran when tasksBySession
|
||||
// changed, freezing the timer at whatever it read on the first render.
|
||||
const taskClock = ref(0);
|
||||
let taskClockTimer: ReturnType<typeof setInterval> | null = null;
|
||||
watch(
|
||||
() => activeAppTasks.value.some((tk) => tk.status === 'running'),
|
||||
(hasRunning) => {
|
||||
if (hasRunning && taskClockTimer === null) {
|
||||
taskClockTimer = setInterval(() => {
|
||||
taskClock.value = (taskClock.value + 1) % Number.MAX_SAFE_INTEGER;
|
||||
}, 1000);
|
||||
} else if (!hasRunning && taskClockTimer !== null) {
|
||||
clearInterval(taskClockTimer);
|
||||
taskClockTimer = null;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Start/stop task output polling based on whether the active session has
|
||||
// running background tasks. This mirrors the TUI's 1-second refresh.
|
||||
watch(
|
||||
() => {
|
||||
const sid = rawState.activeSessionId;
|
||||
if (!sid) return { sid: undefined as string | undefined, hasRunning: false };
|
||||
const tasks = rawState.tasksBySession[sid] ?? [];
|
||||
return { sid, hasRunning: tasks.some((t) => t.status === 'running') };
|
||||
},
|
||||
({ sid, hasRunning }, _prev, onCleanup) => {
|
||||
let cleanupTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
if (hasRunning && sid !== undefined) {
|
||||
startTaskOutputPolling(sid);
|
||||
} else if (sid !== undefined) {
|
||||
// All tasks finished — wait a beat to catch final output, then stop.
|
||||
cleanupTimer = setTimeout(() => {
|
||||
const tasks = rawState.tasksBySession[sid] ?? [];
|
||||
if (!tasks.some((t) => t.status === 'running')) {
|
||||
stopTaskOutputPolling();
|
||||
}
|
||||
}, 1500);
|
||||
} else {
|
||||
stopTaskOutputPolling();
|
||||
}
|
||||
onCleanup(() => {
|
||||
if (cleanupTimer !== undefined) clearTimeout(cleanupTimer);
|
||||
});
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
);
|
||||
|
||||
return {
|
||||
taskClock: computed(() => taskClock.value),
|
||||
loadTasksForSession,
|
||||
};
|
||||
}
|
||||
1657
apps/kimi-web/src/composables/client/useWorkspaceState.ts
Normal file
1657
apps/kimi-web/src/composables/client/useWorkspaceState.ts
Normal file
File diff suppressed because it is too large
Load diff
67
apps/kimi-web/src/composables/useAuthGate.ts
Normal file
67
apps/kimi-web/src/composables/useAuthGate.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// apps/kimi-web/src/composables/useAuthGate.ts
|
||||
// Auth readiness gates the main app. Once the first load finishes and auth is
|
||||
// still missing, show a full-page login entry instead of an in-app banner.
|
||||
|
||||
import { computed, onUnmounted, ref, watch, type Ref } from 'vue';
|
||||
import type { useKimiWebClient } from './useKimiWebClient';
|
||||
|
||||
type KimiWebClient = ReturnType<typeof useKimiWebClient>;
|
||||
|
||||
export interface UseAuthGateOptions {
|
||||
client: KimiWebClient;
|
||||
/** Template ref to the auth-page logo SVG; owned by the component so the
|
||||
template `ref=` binding links, passed here so the blink handler can drive it. */
|
||||
authLogoRef: Ref<SVGSVGElement | null>;
|
||||
}
|
||||
|
||||
export function useAuthGate({ client, authLogoRef }: UseAuthGateOptions) {
|
||||
const authReady = computed(() => client.authReady.value);
|
||||
const showAuthGate = computed(() => client.initialized.value && !authReady.value);
|
||||
const LOGIN_PATH = '/login';
|
||||
const authReturnPath = ref<string | null>(null);
|
||||
let authLogoBlinkTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function currentPathWithSuffix(): string {
|
||||
if (typeof window === 'undefined') return '/';
|
||||
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
}
|
||||
|
||||
function replaceBrowserPath(path: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.history.replaceState(window.history.state, '', path);
|
||||
}
|
||||
|
||||
watch(showAuthGate, (show) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (show) {
|
||||
if (window.location.pathname !== LOGIN_PATH) {
|
||||
authReturnPath.value = currentPathWithSuffix();
|
||||
replaceBrowserPath(LOGIN_PATH);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (window.location.pathname === LOGIN_PATH) {
|
||||
replaceBrowserPath(authReturnPath.value ?? '/');
|
||||
authReturnPath.value = null;
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
function blinkAuthLogo(): void {
|
||||
const el = authLogoRef.value;
|
||||
if (!el) return;
|
||||
el.classList.remove('blink-now');
|
||||
void el.getBoundingClientRect();
|
||||
el.classList.add('blink-now');
|
||||
if (authLogoBlinkTimer !== null) clearTimeout(authLogoBlinkTimer);
|
||||
authLogoBlinkTimer = setTimeout(() => {
|
||||
authLogoBlinkTimer = null;
|
||||
el.classList.remove('blink-now');
|
||||
}, 300);
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (authLogoBlinkTimer !== null) clearTimeout(authLogoBlinkTimer);
|
||||
});
|
||||
|
||||
return { showAuthGate, blinkAuthLogo };
|
||||
}
|
||||
257
apps/kimi-web/src/composables/useDetailPanel.ts
Normal file
257
apps/kimi-web/src/composables/useDetailPanel.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
// apps/kimi-web/src/composables/useDetailPanel.ts
|
||||
// Unified right-side detail layer. Only one detail is open at a time.
|
||||
|
||||
import { computed, ref, watch, type Ref } from 'vue';
|
||||
import type { AgentMember } from '../types';
|
||||
import type { DetailTarget } from './useFilePreview';
|
||||
import type { useKimiWebClient } from './useKimiWebClient';
|
||||
|
||||
type KimiWebClient = ReturnType<typeof useKimiWebClient>;
|
||||
|
||||
const PREVIEW_WIDTH_KEY = 'kimi-web.file-preview-width';
|
||||
const PREVIEW_MIN = 320;
|
||||
|
||||
export interface UseDetailPanelOptions {
|
||||
client: KimiWebClient;
|
||||
/** Mirrored sidebar width (px) so the preview max-width stays within the viewport. */
|
||||
sideWidth: Ref<number>;
|
||||
/** Shared owner of the single right-side slot (also written by useFilePreview). */
|
||||
detailTarget: Ref<DetailTarget | null>;
|
||||
/** Closes the file preview; injected to avoid a composable-to-composable import cycle. */
|
||||
closeFilePreview: () => void;
|
||||
}
|
||||
|
||||
export function useDetailPanel({
|
||||
client,
|
||||
sideWidth,
|
||||
detailTarget,
|
||||
closeFilePreview,
|
||||
}: UseDetailPanelOptions) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Panel width helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function previewAreaWidth(): number {
|
||||
if (typeof window === 'undefined') return PREVIEW_MIN * 2;
|
||||
return Math.max(0, window.innerWidth - sideWidth.value);
|
||||
}
|
||||
|
||||
function clampPreviewWidth(width: number): number {
|
||||
const max = Math.max(PREVIEW_MIN, previewAreaWidth() - PREVIEW_MIN);
|
||||
return Math.min(max, Math.max(PREVIEW_MIN, Math.round(width)));
|
||||
}
|
||||
|
||||
function defaultPreviewWidth(): number {
|
||||
return clampPreviewWidth(previewAreaWidth() / 2);
|
||||
}
|
||||
|
||||
const previewDefaultWidth = computed(() => defaultPreviewWidth());
|
||||
const previewMaxWidth = computed(() => Math.max(PREVIEW_MIN, previewAreaWidth() - PREVIEW_MIN));
|
||||
const previewWidth = ref(previewDefaultWidth.value);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Thinking panel
|
||||
// ---------------------------------------------------------------------------
|
||||
const thinkingTarget = ref<{ turnId: string; blockIndex: number } | null>(null);
|
||||
|
||||
const thinkingPanelText = computed<string | null>(() => {
|
||||
const target = thinkingTarget.value;
|
||||
if (!target) return null;
|
||||
const turn = client.turns.value.find((tn) => tn.id === target.turnId);
|
||||
const blk = turn?.blocks?.[target.blockIndex];
|
||||
return blk?.kind === 'thinking' ? blk.thinking : null;
|
||||
});
|
||||
|
||||
const thinkingVisible = computed(() => thinkingPanelText.value !== null);
|
||||
|
||||
function openThinkingPanel(target: { turnId: string; blockIndex: number }): void {
|
||||
const current = thinkingTarget.value;
|
||||
if (current && current.turnId === target.turnId && current.blockIndex === target.blockIndex) {
|
||||
thinkingTarget.value = null;
|
||||
if (detailTarget.value === 'thinking') detailTarget.value = null;
|
||||
return;
|
||||
}
|
||||
detailTarget.value = 'thinking';
|
||||
thinkingTarget.value = target;
|
||||
}
|
||||
|
||||
function closeThinkingPanel(): void {
|
||||
thinkingTarget.value = null;
|
||||
if (detailTarget.value === 'thinking') detailTarget.value = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compaction summary panel
|
||||
// ---------------------------------------------------------------------------
|
||||
const compactionTarget = ref<{ turnId: string } | null>(null);
|
||||
|
||||
const compactionPanelText = computed<string | null>(() => {
|
||||
const target = compactionTarget.value;
|
||||
if (!target) return null;
|
||||
const turn = client.turns.value.find((tn) => tn.id === target.turnId);
|
||||
return turn?.role === 'compaction' && turn.text ? turn.text : null;
|
||||
});
|
||||
|
||||
const compactionPanelVisible = computed(() => compactionPanelText.value !== null);
|
||||
|
||||
function openCompactionPanel(target: { turnId: string }): void {
|
||||
if (compactionTarget.value?.turnId === target.turnId) {
|
||||
compactionTarget.value = null;
|
||||
if (detailTarget.value === 'compaction') detailTarget.value = null;
|
||||
return;
|
||||
}
|
||||
detailTarget.value = 'compaction';
|
||||
compactionTarget.value = target;
|
||||
}
|
||||
|
||||
function closeCompactionPanel(): void {
|
||||
compactionTarget.value = null;
|
||||
if (detailTarget.value === 'compaction') detailTarget.value = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subagent detail panel
|
||||
// ---------------------------------------------------------------------------
|
||||
const agentTarget = ref<{ turnId: string; blockIndex: number; memberId: string } | null>(null);
|
||||
|
||||
const agentPanelMember = computed<AgentMember | null>(() => {
|
||||
const target = agentTarget.value;
|
||||
if (!target) return null;
|
||||
const turn = client.turns.value.find((tn) => tn.id === target.turnId);
|
||||
const blk = turn?.blocks?.[target.blockIndex];
|
||||
if (!blk) return null;
|
||||
if (blk.kind === 'agent') return blk.member.id === target.memberId ? blk.member : null;
|
||||
if (blk.kind === 'agentGroup') return blk.members.find((m) => m.id === target.memberId) ?? null;
|
||||
return null;
|
||||
});
|
||||
|
||||
const agentPanelVisible = computed(() => agentPanelMember.value !== null);
|
||||
|
||||
function openAgentPanel(target: { turnId: string; blockIndex: number; memberId: string }): void {
|
||||
const current = agentTarget.value;
|
||||
if (current && current.turnId === target.turnId && current.memberId === target.memberId) {
|
||||
agentTarget.value = null;
|
||||
if (detailTarget.value === 'agent') detailTarget.value = null;
|
||||
return;
|
||||
}
|
||||
detailTarget.value = 'agent';
|
||||
agentTarget.value = target;
|
||||
}
|
||||
|
||||
function closeAgentPanel(): void {
|
||||
agentTarget.value = null;
|
||||
if (detailTarget.value === 'agent') detailTarget.value = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Diff detail layer (opened from the chat header git area)
|
||||
// ---------------------------------------------------------------------------
|
||||
const detailDiffMode = ref<'list' | 'detail'>('list');
|
||||
const detailDiffPath = ref<string | null>(null);
|
||||
|
||||
function openDiffDetail(): void {
|
||||
detailTarget.value = 'diff';
|
||||
detailDiffMode.value = 'list';
|
||||
detailDiffPath.value = null;
|
||||
void client.loadGitStatus(client.activeSessionId.value!);
|
||||
}
|
||||
|
||||
function closeDiffDetail(): void {
|
||||
if (detailTarget.value === 'diff') detailTarget.value = null;
|
||||
detailDiffMode.value = 'list';
|
||||
detailDiffPath.value = null;
|
||||
client.clearFileDiff();
|
||||
}
|
||||
|
||||
async function selectDiffFile(path: string): Promise<void> {
|
||||
detailDiffMode.value = 'detail';
|
||||
detailDiffPath.value = path;
|
||||
await client.loadFileDiff(path);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Side chat (BTW) — now rendered in the unified right-side detail layer.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function openSideChatTab(prompt?: string): Promise<void> {
|
||||
await client.openSideChat(prompt);
|
||||
detailTarget.value = 'btw';
|
||||
}
|
||||
|
||||
function closeSideChat(): void {
|
||||
client.closeSideChat();
|
||||
if (detailTarget.value === 'btw') detailTarget.value = null;
|
||||
}
|
||||
|
||||
// Only hides the right-side BTW panel; the side-chat target is per-session and
|
||||
// preserved so switching back to a session restores its BTW transcript.
|
||||
function hideSideChatPanel(): void {
|
||||
if (detailTarget.value === 'btw') detailTarget.value = null;
|
||||
}
|
||||
|
||||
const btwVisible = computed(() => client.sideChatVisible.value);
|
||||
|
||||
/** Any occupant of the shared right-side slot. */
|
||||
const sidePanelVisible = computed(
|
||||
() =>
|
||||
detailTarget.value !== null &&
|
||||
(detailTarget.value !== 'thinking' || thinkingVisible.value) &&
|
||||
(detailTarget.value !== 'compaction' || compactionPanelVisible.value) &&
|
||||
(detailTarget.value !== 'agent' || agentPanelVisible.value) &&
|
||||
(detailTarget.value !== 'btw' || btwVisible.value),
|
||||
);
|
||||
|
||||
/** True while the panel's resize handle is being dragged — the width
|
||||
transition is disabled so the panel follows the pointer 1:1. */
|
||||
const panelDragging = ref(false);
|
||||
|
||||
// Escape closes whichever transient right-side detail panel is open.
|
||||
function closeOpenSidePanel(): boolean {
|
||||
if (detailTarget.value === 'thinking' && thinkingVisible.value) { closeThinkingPanel(); return true; }
|
||||
if (detailTarget.value === 'compaction' && compactionPanelVisible.value) { closeCompactionPanel(); return true; }
|
||||
if (detailTarget.value === 'agent' && agentPanelVisible.value) { closeAgentPanel(); return true; }
|
||||
if (detailTarget.value === 'file') { closeFilePreview(); return true; }
|
||||
if (detailTarget.value === 'diff') { closeDiffDetail(); return true; }
|
||||
if (detailTarget.value === 'btw') { closeSideChat(); return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
watch(client.activeSessionId, () => {
|
||||
closeFilePreview();
|
||||
closeThinkingPanel();
|
||||
closeCompactionPanel();
|
||||
closeAgentPanel();
|
||||
closeDiffDetail();
|
||||
hideSideChatPanel();
|
||||
});
|
||||
|
||||
return {
|
||||
PREVIEW_WIDTH_KEY,
|
||||
PREVIEW_MIN,
|
||||
previewDefaultWidth,
|
||||
previewMaxWidth,
|
||||
previewWidth,
|
||||
thinkingPanelText,
|
||||
thinkingVisible,
|
||||
openThinkingPanel,
|
||||
closeThinkingPanel,
|
||||
compactionPanelText,
|
||||
compactionPanelVisible,
|
||||
openCompactionPanel,
|
||||
closeCompactionPanel,
|
||||
agentPanelMember,
|
||||
agentPanelVisible,
|
||||
openAgentPanel,
|
||||
closeAgentPanel,
|
||||
detailDiffMode,
|
||||
detailDiffPath,
|
||||
openDiffDetail,
|
||||
closeDiffDetail,
|
||||
selectDiffFile,
|
||||
btwVisible,
|
||||
openSideChatTab,
|
||||
closeSideChat,
|
||||
hideSideChatPanel,
|
||||
sidePanelVisible,
|
||||
panelDragging,
|
||||
closeOpenSidePanel,
|
||||
};
|
||||
}
|
||||
190
apps/kimi-web/src/composables/useFilePreview.ts
Normal file
190
apps/kimi-web/src/composables/useFilePreview.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
// apps/kimi-web/src/composables/useFilePreview.ts
|
||||
// File preview: download / path normalization / request-sequence guard. Claims
|
||||
// the 'file' slot of the shared right-side detail layer.
|
||||
|
||||
import { computed, ref, type Ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { FileData, FilePreviewRequest, ToolMedia } from '../types';
|
||||
import type { useKimiWebClient } from './useKimiWebClient';
|
||||
|
||||
type KimiWebClient = ReturnType<typeof useKimiWebClient>;
|
||||
|
||||
/** Which occupant currently owns the shared right-side detail layer. */
|
||||
export type DetailTarget = 'file' | 'diff' | 'thinking' | 'compaction' | 'agent' | 'btw';
|
||||
|
||||
export interface UseFilePreviewOptions {
|
||||
client: KimiWebClient;
|
||||
detailTarget: Ref<DetailTarget | null>;
|
||||
}
|
||||
|
||||
export function useFilePreview({ client, detailTarget }: UseFilePreviewOptions) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const previewTarget = ref<FilePreviewRequest | null>(null);
|
||||
const previewFile = ref<FileData | null>(null);
|
||||
const previewLoading = ref(false);
|
||||
const previewError = ref<string | null>(null);
|
||||
// Normalized workspace-relative path of the currently-open preview. Used for
|
||||
// the download URL so it matches the server's relative-path contract even when
|
||||
// the user opened the preview from an absolute path in the chat.
|
||||
const previewNormalizedPath = ref<string | null>(null);
|
||||
// Incremented on every openFilePreview call so a slower earlier request can't
|
||||
// overwrite the result of a later one (request-sequence guard).
|
||||
let previewRequestSeq = 0;
|
||||
|
||||
const previewDownloadUrl = computed(() => {
|
||||
const path = previewNormalizedPath.value;
|
||||
return path ? client.getFileDownloadUrl(path) : null;
|
||||
});
|
||||
const previewExternalActions = computed(() => previewTarget.value !== null);
|
||||
|
||||
function trimTrailingSlash(path: string): string {
|
||||
return path.length > 1 ? path.replace(/\/+$/, '') : path;
|
||||
}
|
||||
|
||||
function normalizeRelativePath(path: string): string {
|
||||
const out: string[] = [];
|
||||
for (const part of path.split(/[\\/]+/)) {
|
||||
if (!part || part === '.') continue;
|
||||
if (part === '..') {
|
||||
out.pop();
|
||||
continue;
|
||||
}
|
||||
out.push(part);
|
||||
}
|
||||
return out.join('/');
|
||||
}
|
||||
|
||||
function normalizePreviewPath(inputPath: string): { path: string } | { error: string } {
|
||||
const raw = inputPath.trim();
|
||||
if (!raw) return { error: t('filePreview.errors.emptyPath') };
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) {
|
||||
return { error: t('filePreview.errors.unsupportedPath') };
|
||||
}
|
||||
if (raw.startsWith('~')) {
|
||||
return { error: t('filePreview.errors.outsideWorkspace') };
|
||||
}
|
||||
|
||||
const cwd = trimTrailingSlash(client.status.value.cwd);
|
||||
if (raw.startsWith('/')) {
|
||||
if (!cwd || (raw !== cwd && !raw.startsWith(`${cwd}/`))) {
|
||||
return { error: t('filePreview.errors.outsideWorkspace') };
|
||||
}
|
||||
const relative = raw === cwd ? '' : raw.slice(cwd.length + 1);
|
||||
if (relative.split(/[\\/]+/).includes('..')) {
|
||||
return { error: t('filePreview.errors.outsideWorkspace') };
|
||||
}
|
||||
const path = normalizeRelativePath(relative);
|
||||
return path ? { path } : { error: t('filePreview.errors.isDirectory') };
|
||||
}
|
||||
|
||||
if (raw.split(/[\\/]+/).includes('..')) {
|
||||
return { error: t('filePreview.errors.outsideWorkspace') };
|
||||
}
|
||||
|
||||
const path = normalizeRelativePath(raw);
|
||||
return path ? { path } : { error: t('filePreview.errors.emptyPath') };
|
||||
}
|
||||
|
||||
async function openFilePreview(target: FilePreviewRequest): Promise<void> {
|
||||
const requestSeq = ++previewRequestSeq;
|
||||
detailTarget.value = 'file';
|
||||
previewFile.value = null;
|
||||
previewError.value = null;
|
||||
previewLoading.value = true;
|
||||
previewTarget.value = target;
|
||||
previewNormalizedPath.value = null;
|
||||
|
||||
const normalized = normalizePreviewPath(target.path);
|
||||
if ('error' in normalized) {
|
||||
previewLoading.value = false;
|
||||
previewError.value = normalized.error;
|
||||
return;
|
||||
}
|
||||
previewNormalizedPath.value = normalized.path;
|
||||
|
||||
try {
|
||||
const result = await client.readFileContent(normalized.path);
|
||||
// A newer openFilePreview started while this one was in flight — discard
|
||||
// the stale result so the right-side panel shows the latest file.
|
||||
if (requestSeq !== previewRequestSeq) return;
|
||||
if (result) {
|
||||
previewFile.value = { ...result, path: result.path || normalized.path };
|
||||
} else {
|
||||
previewFile.value = {
|
||||
path: normalized.path,
|
||||
content: '',
|
||||
encoding: 'utf-8',
|
||||
mime: 'text/plain',
|
||||
isBinary: false,
|
||||
size: 0,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
if (requestSeq !== previewRequestSeq) return;
|
||||
previewError.value = err instanceof Error ? err.message : t('filePreview.errors.loadFailed');
|
||||
} finally {
|
||||
if (requestSeq === previewRequestSeq) {
|
||||
previewLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mimeFromDataUrl(url: string): string | undefined {
|
||||
const match = /^data:([^;,]+)/i.exec(url);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
function openMediaPreview(media: ToolMedia): void {
|
||||
if (media.kind !== 'image') return;
|
||||
detailTarget.value = 'file';
|
||||
previewTarget.value = null;
|
||||
previewNormalizedPath.value = null;
|
||||
previewError.value = null;
|
||||
previewLoading.value = false;
|
||||
previewFile.value = {
|
||||
path: media.path ?? 'ReadMediaFile image',
|
||||
content: '',
|
||||
encoding: 'utf-8',
|
||||
mime: media.mimeType ?? mimeFromDataUrl(media.url) ?? 'image/*',
|
||||
sourceUrl: media.url,
|
||||
isBinary: true,
|
||||
size: media.bytes ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function closeFilePreview(): void {
|
||||
previewTarget.value = null;
|
||||
previewNormalizedPath.value = null;
|
||||
previewFile.value = null;
|
||||
previewError.value = null;
|
||||
previewLoading.value = false;
|
||||
if (detailTarget.value === 'file') detailTarget.value = null;
|
||||
}
|
||||
|
||||
function openPreviewInEditor(): void {
|
||||
const path = previewFile.value?.path ?? previewTarget.value?.path;
|
||||
if (!path) return;
|
||||
void client.openWorkspaceFile(path, previewTarget.value?.line);
|
||||
}
|
||||
|
||||
function revealPreviewFile(): void {
|
||||
const path = previewFile.value?.path ?? previewTarget.value?.path;
|
||||
if (!path) return;
|
||||
void client.revealWorkspaceFile(path);
|
||||
}
|
||||
|
||||
return {
|
||||
previewTarget,
|
||||
previewFile,
|
||||
previewLoading,
|
||||
previewError,
|
||||
previewDownloadUrl,
|
||||
previewExternalActions,
|
||||
openFilePreview,
|
||||
openMediaPreview,
|
||||
closeFilePreview,
|
||||
openPreviewInEditor,
|
||||
revealPreviewFile,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
55
apps/kimi-web/src/composables/usePageTitle.ts
Normal file
55
apps/kimi-web/src/composables/usePageTitle.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// apps/kimi-web/src/composables/usePageTitle.ts
|
||||
// Static page title (app name only). The session title and workspace name are
|
||||
// intentionally excluded so the tab title stays stable.
|
||||
// Prefix an animated spinner when the agent is running so users can see activity
|
||||
// at a glance.
|
||||
|
||||
import { computed, onUnmounted, ref, watch, watchEffect, type Ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
export interface UsePageTitleOptions {
|
||||
running: Ref<boolean>;
|
||||
showAuthGate: Ref<boolean>;
|
||||
}
|
||||
|
||||
export function usePageTitle({ running, showAuthGate }: UsePageTitleOptions): void {
|
||||
const { t } = useI18n();
|
||||
|
||||
const SPINNER_FRAMES = ['◐', '◓', '◑', '◒'];
|
||||
const spinnerFrame = ref(0);
|
||||
let spinnerTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function startSpinner(): void {
|
||||
if (spinnerTimer !== null) return;
|
||||
spinnerFrame.value = 0;
|
||||
spinnerTimer = setInterval(() => {
|
||||
spinnerFrame.value = (spinnerFrame.value + 1) % SPINNER_FRAMES.length;
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function stopSpinner(): void {
|
||||
if (spinnerTimer !== null) {
|
||||
clearInterval(spinnerTimer);
|
||||
spinnerTimer = null;
|
||||
}
|
||||
spinnerFrame.value = 0;
|
||||
}
|
||||
|
||||
watch(running, (isRunning) => {
|
||||
if (isRunning) startSpinner();
|
||||
else stopSpinner();
|
||||
}, { immediate: true });
|
||||
|
||||
const pageTitle = computed<string>(() => {
|
||||
const prefix = running.value ? `${SPINNER_FRAMES[spinnerFrame.value]} ` : '';
|
||||
if (showAuthGate.value) return `${prefix}${t('app.authPageTitle')} - Kimi Code Web`;
|
||||
return `${prefix}Kimi Code Web`;
|
||||
});
|
||||
watchEffect(() => {
|
||||
if (typeof document !== 'undefined') document.title = pageTitle.value;
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopSpinner();
|
||||
});
|
||||
}
|
||||
54
apps/kimi-web/src/composables/useSidebarLayout.ts
Normal file
54
apps/kimi-web/src/composables/useSidebarLayout.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// apps/kimi-web/src/composables/useSidebarLayout.ts
|
||||
// Layout: resizable session column. ResizeHandle owns the column width (with
|
||||
// localStorage persistence); we mirror it here to drive the App grid.
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
import { safeGetString, safeSetString, STORAGE_KEYS } from '../lib/storage';
|
||||
|
||||
const SIDEBAR_WIDTH_KEY = STORAGE_KEYS.sidebarWidth;
|
||||
const SIDEBAR_COLLAPSED_KEY = STORAGE_KEYS.sidebarCollapsed;
|
||||
const SIDEBAR_DEFAULT = 270;
|
||||
const SIDEBAR_MIN = 170;
|
||||
const SIDEBAR_MAX = 420;
|
||||
const SIDEBAR_COLLAPSED_WIDTH = 36;
|
||||
|
||||
export function useSidebarLayout() {
|
||||
const sessionColWidth = ref(SIDEBAR_DEFAULT);
|
||||
const sidebarCollapsed = ref(false);
|
||||
const sideWidth = computed(() =>
|
||||
sidebarCollapsed.value ? SIDEBAR_COLLAPSED_WIDTH : sessionColWidth.value,
|
||||
);
|
||||
|
||||
function loadSidebarCollapsed(): void {
|
||||
try {
|
||||
sidebarCollapsed.value = safeGetString(SIDEBAR_COLLAPSED_KEY) === 'true';
|
||||
} catch {
|
||||
sidebarCollapsed.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function saveSidebarCollapsed(): void {
|
||||
try {
|
||||
safeSetString(SIDEBAR_COLLAPSED_KEY, String(sidebarCollapsed.value));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSidebarCollapse(): void {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value;
|
||||
saveSidebarCollapsed();
|
||||
}
|
||||
|
||||
return {
|
||||
SIDEBAR_WIDTH_KEY,
|
||||
SIDEBAR_DEFAULT,
|
||||
SIDEBAR_MIN,
|
||||
SIDEBAR_MAX,
|
||||
sessionColWidth,
|
||||
sidebarCollapsed,
|
||||
sideWidth,
|
||||
loadSidebarCollapsed,
|
||||
toggleSidebarCollapse,
|
||||
};
|
||||
}
|
||||
|
|
@ -5,6 +5,19 @@ import type { AppSessionStatus } from './api/types';
|
|||
list can distinguish awaiting / aborted instead of collapsing to running|idle. */
|
||||
export type SessionStatus = AppSessionStatus;
|
||||
|
||||
/** File content loaded for preview (text or base64-encoded binary). */
|
||||
export interface FileData {
|
||||
path: string;
|
||||
content: string;
|
||||
encoding: 'utf-8' | 'base64';
|
||||
mime: string;
|
||||
sourceUrl?: string;
|
||||
languageId?: string;
|
||||
isBinary: boolean;
|
||||
size: number;
|
||||
lineCount?: number;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
title: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue