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:
qer 2026-06-22 23:56:14 +08:00 committed by GitHub
parent d4ae02d82e
commit 8c6cade69e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 3513 additions and 2847 deletions

View 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.

View file

@ -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);

View file

@ -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;

View 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>;

View 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>;

View 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,
};
}

File diff suppressed because it is too large Load diff

View 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 };
}

View 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,
};
}

View 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

View 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();
});
}

View 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,
};
}

View file

@ -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;