Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/web-storage-appearance-notification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Consolidate web client localStorage access and decouple appearance/notification state into dedicated modules.
9 changes: 5 additions & 4 deletions apps/kimi-web/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { useKimiWebClient } from './composables/useKimiWebClient';
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);
Expand Down Expand Up @@ -204,8 +205,8 @@ 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 = 'kimi-web.sidebar-width';
const SIDEBAR_COLLAPSED_KEY = 'kimi-web.sidebar-collapsed';
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;
Expand All @@ -219,15 +220,15 @@ const sideWidth = computed(() =>

function loadSidebarCollapsed(): void {
try {
sidebarCollapsed.value = localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === 'true';
sidebarCollapsed.value = safeGetString(SIDEBAR_COLLAPSED_KEY) === 'true';
} catch {
sidebarCollapsed.value = false;
}
}

function saveSidebarCollapsed(): void {
try {
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(sidebarCollapsed.value));
safeSetString(SIDEBAR_COLLAPSED_KEY, String(sidebarCollapsed.value));
} catch {
// ignore
}
Expand Down
8 changes: 5 additions & 3 deletions apps/kimi-web/src/api/config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// apps/kimi-web/src/api/config.ts
// Reads Vite env, builds REST/WS URLs, manages stable clientId.

const CLIENT_ID_KEY = 'kimi-web.client-id';
import { safeGetString, safeSetString, STORAGE_KEYS } from '../lib/storage';

const CLIENT_ID_KEY = STORAGE_KEYS.clientId;
const WEB_CLIENT_NAME = 'kimi-code-web';
const WEB_CLIENT_UI_MODE = 'web';

Expand Down Expand Up @@ -86,10 +88,10 @@ export function buildWsUrl(origin: string, clientId: string): string {
}

function getClientId(): string {
const stored = globalThis.localStorage?.getItem(CLIENT_ID_KEY);
const stored = safeGetString(CLIENT_ID_KEY);
if (stored) return stored;
const generated = `web_${globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2)}`;
globalThis.localStorage?.setItem(CLIENT_ID_KEY, generated);
safeSetString(CLIENT_ID_KEY, generated);
return generated;
}

Expand Down
21 changes: 5 additions & 16 deletions apps/kimi-web/src/components/Composer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { FileItem } from './MentionMenu.vue';
import type { ActivationBadges, ConversationStatus, PermissionMode, QueuedPromptView } from '../types';
import type { AppModel, AppSkill, ThinkingLevel } from '../api/types';
import { modelThinkingAvailability } from '../lib/modelThinking';
import { draftStorageKey, safeGetString, safeRemove, safeSetString } from '../lib/storage';

// ---------------------------------------------------------------------------
// Attachment state
Expand Down Expand Up @@ -104,25 +105,13 @@ const { t } = useI18n();
// Unsent-draft persistence: the composer text is kept in localStorage PER
// SESSION, so switching away and back (or a page refresh) restores whatever the
// user was typing for that session. Cleared when the draft is sent/steered.
const DRAFT_PREFIX = 'kimi-web.draft.';
function draftKey(sid: string | undefined): string {
return DRAFT_PREFIX + (sid && sid.length > 0 ? sid : '__new__');
}
function loadDraft(sid: string | undefined): string {
try {
return localStorage.getItem(draftKey(sid)) ?? '';
} catch {
return '';
}
return safeGetString(draftStorageKey(sid)) ?? '';
}
function saveDraft(sid: string | undefined, value: string): void {
try {
const key = draftKey(sid);
if (value) localStorage.setItem(key, value);
else localStorage.removeItem(key);
} catch {
// localStorage unavailable (private mode / quota) — drafts just don't persist.
}
const key = draftStorageKey(sid);
if (value) safeSetString(key, value);
else safeRemove(key);
}

const text = ref(loadDraft(props.sessionId));
Expand Down
7 changes: 2 additions & 5 deletions apps/kimi-web/src/components/ConversationPane.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import Composer from './Composer.vue';
import SwarmCard from './SwarmCard.vue';
import ChatDock from './ChatDock.vue';
import { getVisibleWorkspaces } from '../lib/workspacePicker';
import { safeRemove, STORAGE_KEYS } from '../lib/storage';

const props = defineProps<{
turns: ChatTurn[];
Expand Down Expand Up @@ -172,11 +173,7 @@ const { t } = useI18n();
// The align toggle was removed with its UI (6e50cb7) — reading layout is
// always centered now. Drop the old persisted preference so users who once
// picked 'left' aren't frozen on it with no way back.
try {
localStorage.removeItem('kimi-web.content-align');
} catch {
// localStorage unavailable
}
safeRemove(STORAGE_KEYS.contentAlign);

const chatPaneRef = ref<InstanceType<typeof ChatPane> | null>(null);
const emptyComposerRef = ref<{ loadForEdit: (v: string) => void } | null>(null);
Expand Down
7 changes: 4 additions & 3 deletions apps/kimi-web/src/components/OpenInMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { safeGetString, safeSetString, STORAGE_KEYS } from '../lib/storage';

const { t } = useI18n();

Expand Down Expand Up @@ -64,12 +65,12 @@ const visibleTargets = computed(() => {
return platformTargets.filter((t) => available.has(t.id));
});

const LAST_TARGET_KEY = 'kimi-web.open-in.last-target';
const LAST_TARGET_KEY = STORAGE_KEYS.openInLastTarget;
const lastTargetId = ref<TargetId | null>(null);

function loadLastTarget(): void {
try {
const raw = localStorage.getItem(LAST_TARGET_KEY);
const raw = safeGetString(LAST_TARGET_KEY);
if (raw && visibleTargets.value.some((t) => t.id === raw)) {
lastTargetId.value = raw as TargetId;
} else {
Expand All @@ -83,7 +84,7 @@ loadLastTarget();

function saveLastTarget(id: TargetId): void {
try {
localStorage.setItem(LAST_TARGET_KEY, id);
safeSetString(LAST_TARGET_KEY, id);
} catch { /* ignore */ }
lastTargetId.value = id;
}
Expand Down
190 changes: 190 additions & 0 deletions apps/kimi-web/src/composables/client/useAppearance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
// apps/kimi-web/src/composables/client/useAppearance.ts
// Appearance preferences (theme / color scheme / accent / UI font size) and
// the streaming "fast moon" spinner state. Pure local UI state: only touches
// storage + the DOM, never rawState or the API. The values are module-level
// singletons so the whole app shares one instance.

import { ref, watch } from 'vue';
import { safeGetString, safeSetString, STORAGE_KEYS } from '../../lib/storage';

/** UI theme: 'terminal' = dense line look, 'modern' = bubbles everywhere,
'kimi' = the official Kimi design language. */
export type Theme = 'terminal' | 'modern' | 'kimi';

/** Color scheme: 'light', 'dark', or follow the OS preference ('system'). */
export type ColorScheme = 'light' | 'dark' | 'system';

/** Accent: 'blue' (Kimi blue, default) or 'mono' (black/white). */
export type Accent = 'blue' | 'mono';

const ACCENT_VALUES: readonly string[] = ['blue', 'mono'];
const COLOR_SCHEME_VALUES: readonly string[] = ['light', 'dark', 'system'];
const UI_FONT_SIZE_DEFAULT = 15;
const UI_FONT_SIZE_MIN = 12;
const UI_FONT_SIZE_MAX = 20;

function loadAccent(): Accent {
const v = safeGetString(STORAGE_KEYS.accent);
if (v && ACCENT_VALUES.includes(v)) return v as Accent;
return 'blue';
}

function applyAccent(a: Accent): void {
if (typeof document === 'undefined' || !document.documentElement) return;
document.documentElement.dataset.accent = a;
}

function loadColorScheme(): ColorScheme {
const v = safeGetString(STORAGE_KEYS.colorScheme);
if (v && COLOR_SCHEME_VALUES.includes(v)) return v as ColorScheme;
return 'system';
}

function applyColorScheme(c: ColorScheme): void {
if (typeof document === 'undefined' || !document.documentElement) return;
document.documentElement.dataset.colorScheme = c;

// Mobile browser chrome (status/address bar) follows <meta name=theme-color>.
const metas = document.querySelectorAll<HTMLMetaElement>('meta[name="theme-color"]');
if (metas.length === 0) return;
const pinned = c === 'dark' ? '#0d1117' : c === 'light' ? '#ffffff' : null;
metas.forEach((meta) => {
const media = meta.getAttribute('media') ?? '';
const systemValue = media.includes('dark') ? '#0d1117' : '#ffffff';
meta.setAttribute('content', pinned ?? systemValue);
});
}

function loadTheme(): Theme {
const v = safeGetString(STORAGE_KEYS.theme);
if (v === 'terminal' || v === 'modern' || v === 'kimi') return v;
return 'modern';
}

function applyTheme(t: Theme): void {
if (typeof document === 'undefined' || !document.documentElement) return;
document.documentElement.dataset.theme = t;
}

function clampUiFontSize(value: number): number {
if (!Number.isFinite(value)) return UI_FONT_SIZE_DEFAULT;
return Math.min(UI_FONT_SIZE_MAX, Math.max(UI_FONT_SIZE_MIN, Math.round(value)));
}

function loadUiFontSize(): number {
const v = safeGetString(STORAGE_KEYS.uiFontSize);
return v === null ? UI_FONT_SIZE_DEFAULT : clampUiFontSize(Number(v));
}

function applyUiFontSize(value: number): void {
if (typeof document === 'undefined' || !document.documentElement) return;
document.documentElement.style.setProperty('--ui-font-size', `${clampUiFontSize(value)}px`);
}

const theme = ref<Theme>(loadTheme());
const colorScheme = ref<ColorScheme>(loadColorScheme());
const accent = ref<Accent>(loadAccent());
const uiFontSize = ref<number>(loadUiFontSize());

watch(theme, applyTheme, { immediate: true });
watch(colorScheme, applyColorScheme, { immediate: true });
watch(accent, applyAccent, { immediate: true });
watch(uiFontSize, applyUiFontSize, { immediate: true });

function setTheme(t: Theme): void {
if (t !== 'terminal' && t !== 'modern' && t !== 'kimi') return;
theme.value = t;
safeSetString(STORAGE_KEYS.theme, t);
}

function toggleTheme(): void {
setTheme(theme.value === 'modern' ? 'terminal' : 'modern');
}

function setColorScheme(c: ColorScheme): void {
if (!COLOR_SCHEME_VALUES.includes(c)) return;
colorScheme.value = c;
safeSetString(STORAGE_KEYS.colorScheme, c);
}

function setAccent(a: Accent): void {
if (!ACCENT_VALUES.includes(a)) return;
accent.value = a;
safeSetString(STORAGE_KEYS.accent, a);
}

function setUiFontSize(value: number): void {
const next = clampUiFontSize(value);
uiFontSize.value = next;
safeSetString(STORAGE_KEYS.uiFontSize, String(next));
}

// CSS handles the moon frames; this only flips the spinner between normal and
// fast classes when the active session is visibly producing content quickly.
const MOON_FAST_WINDOW_MS = 600;
const MOON_FAST_MIN_ELAPSED_MS = 250;
const MOON_FAST_CHECK_INTERVAL_MS = 250;
const MOON_FAST_HOLD_MS = 1000;
const MOON_FAST_CHARS_PER_SECOND = 160;

type MoonSpeedSample = { time: number; chars: number };

const fastMoon = ref(false);
let moonSpeedSamples: MoonSpeedSample[] = [];
let moonFastResetTimer: ReturnType<typeof setTimeout> | null = null;
let lastMoonFastCheckAt = -MOON_FAST_CHECK_INTERVAL_MS;

function resetFastMoon(): void {
moonSpeedSamples = [];
lastMoonFastCheckAt = -MOON_FAST_CHECK_INTERVAL_MS;
fastMoon.value = false;
if (moonFastResetTimer !== null) {
clearTimeout(moonFastResetTimer);
moonFastResetTimer = null;
}
}

function holdFastMoon(): void {
fastMoon.value = true;
if (moonFastResetTimer !== null) clearTimeout(moonFastResetTimer);
moonFastResetTimer = setTimeout(() => {
moonFastResetTimer = null;
moonSpeedSamples = [];
lastMoonFastCheckAt = -MOON_FAST_CHECK_INTERVAL_MS;
fastMoon.value = false;
}, MOON_FAST_HOLD_MS);
}

function recordMoonDelta(chars: number): void {
if (chars <= 0) return;
const now = Date.now();
moonSpeedSamples.push({ time: now, chars });
const cutoff = now - MOON_FAST_WINDOW_MS;
moonSpeedSamples = moonSpeedSamples.filter((s) => s.time >= cutoff);

if (now - lastMoonFastCheckAt < MOON_FAST_CHECK_INTERVAL_MS) return;
lastMoonFastCheckAt = now;

const oldest = moonSpeedSamples[0]?.time ?? now;
const elapsed = Math.max(now - oldest, MOON_FAST_MIN_ELAPSED_MS);
const totalChars = moonSpeedSamples.reduce((sum, s) => sum + s.chars, 0);
const charsPerSecond = (totalChars / elapsed) * 1000;
if (charsPerSecond >= MOON_FAST_CHARS_PER_SECOND) holdFastMoon();
}

export function useAppearance() {
return {
theme,
colorScheme,
accent,
uiFontSize,
fastMoon,
setTheme,
toggleTheme,
setColorScheme,
setAccent,
setUiFontSize,
resetFastMoon,
recordMoonDelta,
};
}
Loading
Loading