mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
Implements the locked P3 design end-to-end: - subagent lifecycle projection (spawned→started→suspended→completed/failed) + inline Agent / AgentGroup cards; swarm progress card (multi-column) derived from swarmIndex; goal dock strip (expandable) from goal.updated; plan/goal/ swarm activation badges in the composer status line. - terminal as a view (xterm + WS terminal_* frames with since_seq replay) and a tab/view-dimension split (usePaneLayout tree + ViewGroup + SplitLayout, VSCode editor-group style), persisted to localStorage. Adds swarm-groups / subagent-goal / agent-group-turns unit tests and stub-daemon seeds. 98 tests pass; vue-tsc + oxlint clean; production build OK. Accepted by review (see reports/web-p3-acceptance.md); no blocking issues.
1000 lines
34 KiB
Vue
1000 lines
34 KiB
Vue
<!-- apps/kimi-web/src/App.vue -->
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, provide, ref, watch } 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 ThinkingPanel from './components/ThinkingPanel.vue';
|
|
import ModelPicker from './components/ModelPicker.vue';
|
|
import ProviderManager from './components/ProviderManager.vue';
|
|
import LoginDialog from './components/LoginDialog.vue';
|
|
import NewSessionDialog from './components/NewSessionDialog.vue';
|
|
import SessionsDialog from './components/SessionsDialog.vue';
|
|
import AddWorkspaceDialog from './components/AddWorkspaceDialog.vue';
|
|
import StatusPanel from './components/StatusPanel.vue';
|
|
import WarningToasts from './components/WarningToasts.vue';
|
|
import MobileTopBar from './components/MobileTopBar.vue';
|
|
import MobileSwitcherSheet from './components/MobileSwitcherSheet.vue';
|
|
import MobileSettingsSheet from './components/MobileSettingsSheet.vue';
|
|
import Onboarding from './components/Onboarding.vue';
|
|
import GlobalLoading from './components/GlobalLoading.vue';
|
|
import DebugPanel from './debug/DebugPanel.vue';
|
|
import { isTraceEnabled } from './debug/trace';
|
|
import { useKimiWebClient } from './composables/useKimiWebClient';
|
|
import { useIsMobile } from './composables/useIsMobile';
|
|
import type { ThinkingLevel } from './api/types';
|
|
import type { FilePreviewRequest, ToolMedia } from './types';
|
|
|
|
const client = useKimiWebClient();
|
|
provide('resolveImage', client.resolveImageUrl);
|
|
const { t } = useI18n();
|
|
|
|
// KAP/daemon debug panel — opt-in via ?debug=1 or localStorage kimi-web.debug=1.
|
|
const debugEnabled = isTraceEnabled();
|
|
|
|
// Narrow viewports (≤640px) render the single-column mobile shell; desktop is
|
|
// unchanged. jsdom defaults to false (desktop) so component tests are unaffected.
|
|
const isMobile = useIsMobile();
|
|
|
|
// Mobile sheet visibility
|
|
const showMobileSwitcher = ref(false);
|
|
const showMobileSettings = ref(false);
|
|
|
|
// Active session title for the mobile top bar.
|
|
const activeSessionTitle = computed<string>(() => {
|
|
const id = client.activeSessionId.value;
|
|
return client.sessions.value.find((s) => s.id === id)?.title ?? '';
|
|
});
|
|
|
|
// Number of sessions in the active workspace (mobile top-bar sub-line).
|
|
const activeWorkspaceSessionCount = computed<number>(
|
|
() => client.visibleWorkspace.value?.sessionCount ?? 0,
|
|
);
|
|
|
|
// Thinking is on/off (TUI parity — no effort-level cycling). The /thinking
|
|
// command flips between off and the backend default effort ('high').
|
|
function nextThinkingLevel(current: ThinkingLevel): ThinkingLevel {
|
|
return current === 'off' ? 'high' : 'off';
|
|
}
|
|
|
|
// First-run onboarding (theme / language / welcome greeting). Shown until the
|
|
// user finishes it once; re-openable from the settings popover.
|
|
const showOnboarding = ref(!client.onboarded.value);
|
|
function completeOnboarding(): void {
|
|
client.setOnboarded(true);
|
|
showOnboarding.value = false;
|
|
}
|
|
function openOnboarding(): void {
|
|
showOnboarding.value = true;
|
|
}
|
|
|
|
onMounted(() => {
|
|
void client.load();
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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_DEFAULT = 270;
|
|
const SIDEBAR_MIN = 170;
|
|
const SIDEBAR_MAX = 420;
|
|
|
|
const sessionColWidth = ref(SIDEBAR_DEFAULT);
|
|
const sideWidth = computed(() => sessionColWidth.value);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Global file preview panel. Chat path links open here; the existing ~/files
|
|
// tab keeps its local split-pane preview.
|
|
// ---------------------------------------------------------------------------
|
|
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);
|
|
let previewRequestSeq = 0;
|
|
|
|
const previewVisible = computed(
|
|
() => previewTarget.value !== null || previewFile.value !== null || previewLoading.value || previewError.value !== null,
|
|
);
|
|
const previewDownloadUrl = computed(() => {
|
|
const path = previewTarget.value?.path;
|
|
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> {
|
|
thinkingTarget.value = null; // shared right-side slot
|
|
compactionTarget.value = null;
|
|
const normalized = normalizePreviewPath(target.path);
|
|
previewTarget.value = target;
|
|
previewFile.value = null;
|
|
previewError.value = null;
|
|
|
|
if ('error' in normalized) {
|
|
previewLoading.value = false;
|
|
previewError.value = normalized.error;
|
|
return;
|
|
}
|
|
|
|
const requestSeq = ++previewRequestSeq;
|
|
previewTarget.value = { path: normalized.path, line: target.line };
|
|
previewLoading.value = true;
|
|
try {
|
|
const file = await client.readFileContent(normalized.path);
|
|
if (requestSeq !== previewRequestSeq) return;
|
|
if (!file) {
|
|
previewError.value = t('filePreview.errors.loadFailed');
|
|
return;
|
|
}
|
|
previewFile.value = file;
|
|
} 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;
|
|
thinkingTarget.value = null;
|
|
compactionTarget.value = null;
|
|
previewRequestSeq++;
|
|
previewTarget.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 {
|
|
previewRequestSeq++;
|
|
previewTarget.value = null;
|
|
previewFile.value = null;
|
|
previewError.value = null;
|
|
previewLoading.value = false;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Thinking panel — shares the right-side slot with the file preview (one open
|
|
// at a time; opening either closes the other). The panel resolves its text
|
|
// reactively from the transcript so a still-streaming block keeps growing.
|
|
// ---------------------------------------------------------------------------
|
|
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;
|
|
});
|
|
|
|
// Visible only while the block still exists (a compaction reload that drops
|
|
// the turn auto-hides the panel).
|
|
const thinkingVisible = computed(() => thinkingPanelText.value !== null);
|
|
|
|
function openThinkingPanel(target: { turnId: string; blockIndex: number }): void {
|
|
// Clicking the SAME thinking block again closes the drawer (toggle).
|
|
const current = thinkingTarget.value;
|
|
if (current && current.turnId === target.turnId && current.blockIndex === target.blockIndex) {
|
|
thinkingTarget.value = null;
|
|
return;
|
|
}
|
|
closeFilePreview();
|
|
compactionTarget.value = null;
|
|
thinkingTarget.value = target;
|
|
}
|
|
|
|
function closeThinkingPanel(): void {
|
|
thinkingTarget.value = null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Compaction summary panel — shares the right-side slot too. Opened from a
|
|
// "context compacted" divider in the transcript; resolves the summary text
|
|
// reactively from the divider turn.
|
|
// ---------------------------------------------------------------------------
|
|
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 {
|
|
// Clicking the SAME divider again closes the drawer (toggle).
|
|
if (compactionTarget.value?.turnId === target.turnId) {
|
|
compactionTarget.value = null;
|
|
return;
|
|
}
|
|
closeFilePreview();
|
|
thinkingTarget.value = null;
|
|
compactionTarget.value = target;
|
|
}
|
|
|
|
function closeCompactionPanel(): void {
|
|
compactionTarget.value = null;
|
|
}
|
|
|
|
/** Any occupant of the shared right-side slot. */
|
|
const sidePanelVisible = computed(
|
|
() => previewVisible.value || thinkingVisible.value || compactionPanelVisible.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();
|
|
});
|
|
|
|
// Reference to ConversationPane so we can imperatively switch tabs
|
|
const conversationPaneRef = ref<InstanceType<typeof ConversationPane> | null>(null);
|
|
|
|
// running: true when activity is not idle
|
|
const running = computed(() => client.activity.value !== 'idle');
|
|
|
|
// Auth readiness — drives onboarding banner
|
|
const authReady = computed(() => client.authReady.value);
|
|
|
|
// Shift-multi-selected workspace ids; when >1 are selected the main pane
|
|
// shows a "coming soon" placeholder instead of the conversation.
|
|
const selectedWorkspaceIds = ref<string[]>([]);
|
|
const hasMultiSelect = computed(() => selectedWorkspaceIds.value.length > 1);
|
|
|
|
function handleSelectWorkspaces(ids: string[]): void {
|
|
selectedWorkspaceIds.value = ids;
|
|
}
|
|
|
|
// Dialog visibility refs
|
|
const showModelPicker = ref(false);
|
|
const showProviders = ref(false);
|
|
const showLogin = ref(false);
|
|
const showNewSession = ref(false);
|
|
const showSessions = ref(false);
|
|
const showAddWorkspace = ref(false);
|
|
const showStatusPanel = ref(false);
|
|
|
|
// Loading state for model/provider fetches
|
|
const modelsLoading = ref(false);
|
|
const modelsUnavailable = ref(false);
|
|
const providersLoading = ref(false);
|
|
const providersUnavailable = ref(false);
|
|
|
|
async function openModelPicker(): Promise<void> {
|
|
modelsLoading.value = true;
|
|
modelsUnavailable.value = false;
|
|
showModelPicker.value = true;
|
|
try {
|
|
await client.loadModels();
|
|
} catch {
|
|
modelsUnavailable.value = true;
|
|
} finally {
|
|
modelsLoading.value = false;
|
|
}
|
|
}
|
|
|
|
async function openProviders(): Promise<void> {
|
|
providersLoading.value = true;
|
|
providersUnavailable.value = false;
|
|
showProviders.value = true;
|
|
try {
|
|
await client.loadProviders();
|
|
} catch {
|
|
providersUnavailable.value = true;
|
|
} finally {
|
|
providersLoading.value = false;
|
|
}
|
|
}
|
|
|
|
function openLogin(): void {
|
|
showLogin.value = true;
|
|
}
|
|
|
|
async function handleSelectModel(modelId: string): Promise<void> {
|
|
showModelPicker.value = false;
|
|
await client.setModel(modelId);
|
|
}
|
|
|
|
async function handleAddProvider(input: { type: string; apiKey?: string; baseUrl?: string; defaultModel?: string }): Promise<void> {
|
|
await client.addProvider(input);
|
|
}
|
|
|
|
async function handleDeleteProvider(id: string): Promise<void> {
|
|
await client.deleteProvider(id);
|
|
}
|
|
|
|
async function handleRefreshProvider(id: string): Promise<void> {
|
|
await client.refreshProvider(id);
|
|
}
|
|
|
|
// LoginDialog callbacks — delegates to composable
|
|
async function handleStartOAuthLogin() {
|
|
return client.startOAuthLogin();
|
|
}
|
|
|
|
async function handlePollOAuthLogin() {
|
|
return client.pollOAuthLogin();
|
|
}
|
|
|
|
async function handleCancelOAuthLogin() {
|
|
return client.cancelOAuthLogin();
|
|
}
|
|
|
|
async function handleLoginSuccess(): Promise<void> {
|
|
showLogin.value = false;
|
|
// Re-check auth state and reload sessions now that we're authenticated
|
|
await client.checkAuth();
|
|
await client.load();
|
|
}
|
|
|
|
// Handler for slash commands emitted by Composer (via ConversationPane)
|
|
function handleCommand(cmd: string): void {
|
|
// `/compact <text>` carries an optional free-text instruction steering what
|
|
// the summary should focus on (TUI parity).
|
|
if (cmd === '/compact' || cmd.startsWith('/compact ')) {
|
|
client.compact(cmd.slice('/compact'.length).trim() || undefined);
|
|
return;
|
|
}
|
|
switch (cmd) {
|
|
case '/new':
|
|
case '/clear':
|
|
showNewSession.value = true;
|
|
break;
|
|
case '/sessions':
|
|
showSessions.value = true;
|
|
break;
|
|
case '/fork':
|
|
void client.forkSession();
|
|
break;
|
|
case '/permission': {
|
|
// Cycle manual → auto → yolo → manual
|
|
const current = client.permission.value;
|
|
const next = current === 'manual' ? 'auto' : current === 'auto' ? 'yolo' : 'manual';
|
|
client.setPermission(next);
|
|
break;
|
|
}
|
|
case '/plan':
|
|
client.togglePlanMode();
|
|
break;
|
|
case '/auto':
|
|
client.setPermission('auto');
|
|
break;
|
|
case '/yolo':
|
|
client.setPermission('yolo');
|
|
break;
|
|
case '/thinking':
|
|
// No popover anchor from a slash command — step to the next level.
|
|
client.setThinking(nextThinkingLevel(client.thinking.value));
|
|
break;
|
|
case '/tasks':
|
|
conversationPaneRef.value?.switchTab('tasks');
|
|
break;
|
|
case '/help':
|
|
client.dismissWarning(-1);
|
|
break;
|
|
case '/status':
|
|
showStatusPanel.value = true;
|
|
break;
|
|
case '/undo':
|
|
client.undo();
|
|
break;
|
|
case '/model':
|
|
void openModelPicker();
|
|
break;
|
|
case '/provider':
|
|
void openProviders();
|
|
break;
|
|
case '/login':
|
|
openLogin();
|
|
break;
|
|
default: {
|
|
// Not a built-in command → treat it as a session skill activation
|
|
// (the user picked `/<skill>` from the menu, or typed `/<skill> args`).
|
|
// The daemon answers an unknown name with skill.not_found, surfaced as a
|
|
// warning, so a stray slash is harmless.
|
|
const space = cmd.indexOf(' ');
|
|
const name = (space === -1 ? cmd : cmd.slice(0, space)).slice(1);
|
|
const args = space === -1 ? undefined : cmd.slice(space + 1).trim() || undefined;
|
|
if (name) void client.activateSkill(name, args);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleUnqueue(index: number): void {
|
|
client.unqueue(index);
|
|
}
|
|
|
|
// Editing a queued message: the Composer already loaded the text into its
|
|
// textarea; here we just remove it from the queue so it isn't sent twice.
|
|
function handleEditQueued(index: number): void {
|
|
client.unqueue(index);
|
|
}
|
|
|
|
async function handleSubmit(payload: { text: string; attachments: { fileId: string }[] }): Promise<void> {
|
|
const wsId = client.activeWorkspaceId.value;
|
|
if (!client.activeSessionId.value && wsId) {
|
|
await client.startSessionAndSendPrompt(wsId, payload.text, payload.attachments);
|
|
return;
|
|
}
|
|
void client.sendPrompt(payload.text, payload.attachments);
|
|
}
|
|
|
|
// Primary "+ New": enter the draft state in the current workspace so the
|
|
// right pane shows the onboarding composer. The session is only created when
|
|
// the user sends the first message.
|
|
function handleCreateSession(): void {
|
|
const wsId = client.activeWorkspaceId.value;
|
|
if (wsId) {
|
|
client.openWorkspaceDraft(wsId);
|
|
} else {
|
|
showNewSession.value = true;
|
|
}
|
|
}
|
|
|
|
// Workspace-level "+ New" (sidebar group or mobile switcher): enter the draft
|
|
// state in the chosen workspace. No backend session is created until the user
|
|
// actually sends a message.
|
|
function handleCreateSessionInWorkspace(workspaceId: string): void {
|
|
client.openWorkspaceDraft(workspaceId);
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
class="app"
|
|
:class="{ mobile: isMobile }"
|
|
:style="{ '--side-w': sideWidth + 'px', '--preview-w': previewWidth + 'px' }"
|
|
>
|
|
<!-- Desktop navigation: workspace rail + resizable session column. -->
|
|
<template v-if="!isMobile">
|
|
<Sidebar
|
|
:col-width="sessionColWidth"
|
|
:active-workspace="client.visibleWorkspace.value"
|
|
:active-workspace-id="client.activeWorkspaceId.value"
|
|
:sessions="client.sessionsForView.value"
|
|
:groups="client.workspaceGroups.value"
|
|
:active-id="client.activeSessionId.value"
|
|
:attention-by-session="client.attentionBySession.value"
|
|
:auth-ready="client.authReady.value"
|
|
:account-model="client.defaultModel.value"
|
|
:theme="client.theme.value"
|
|
:color-scheme="client.colorScheme.value"
|
|
:accent="client.accent.value"
|
|
@select="client.selectSession($event)"
|
|
@create="handleCreateSession"
|
|
@create-in-workspace="handleCreateSessionInWorkspace($event)"
|
|
@select-workspace="client.openWorkspace($event)"
|
|
@add-workspace="showAddWorkspace = true"
|
|
@rename="(id, title) => client.renameSession(id, title)"
|
|
@delete="(id) => client.deleteSession(id)"
|
|
@rename-workspace="(id, name) => client.renameWorkspace(id, name)"
|
|
@delete-workspace="(id) => client.deleteWorkspace(id)"
|
|
@select-workspaces="handleSelectWorkspaces"
|
|
@login="openLogin"
|
|
@logout="client.logout"
|
|
@set-theme="client.setTheme($event)"
|
|
@set-color-scheme="client.setColorScheme($event)"
|
|
@set-accent="client.setAccent($event)"
|
|
@open-onboarding="openOnboarding"
|
|
/>
|
|
<ResizeHandle
|
|
:storage-key="SIDEBAR_WIDTH_KEY"
|
|
:default-width="SIDEBAR_DEFAULT"
|
|
:min="SIDEBAR_MIN"
|
|
:max="SIDEBAR_MAX"
|
|
@update:width="sessionColWidth = $event"
|
|
/>
|
|
</template>
|
|
|
|
<!-- Mobile navigation: slim top bar (switcher + settings sheets). -->
|
|
<MobileTopBar
|
|
v-else
|
|
:workspace="client.visibleWorkspace.value"
|
|
:session-title="activeSessionTitle"
|
|
:running="running"
|
|
:branch="client.status.value.branch"
|
|
:session-count="activeWorkspaceSessionCount"
|
|
@open-switcher="showMobileSwitcher = true"
|
|
@open-settings="showMobileSettings = true"
|
|
/>
|
|
|
|
<ConversationPane
|
|
v-if="!hasMultiSelect"
|
|
ref="conversationPaneRef"
|
|
:mobile="isMobile"
|
|
:modern="client.theme.value === 'modern' || client.theme.value === 'kimi'"
|
|
:turns="client.turns.value"
|
|
:session-id="client.activeSessionId.value"
|
|
:approvals="client.pendingApprovals.value"
|
|
:changes="client.changes.value"
|
|
:git-info="client.gitInfo.value"
|
|
:file-diff="client.fileDiff.value"
|
|
:selected-diff-path="client.selectedDiffPath.value"
|
|
:file-diff-loading="client.fileDiffLoading.value"
|
|
:load-file-diff="client.loadFileDiff"
|
|
:clear-file-diff="client.clearFileDiff"
|
|
:tasks="client.tasks.value"
|
|
:todos="client.todos.value"
|
|
:goal="client.goal.value"
|
|
:swarms="client.swarms.value"
|
|
:activation-badges="client.activationBadges.value"
|
|
:status="client.status.value"
|
|
:thinking="client.thinking.value"
|
|
:plan-mode="client.planMode.value"
|
|
:models="client.models.value"
|
|
:skills="client.skills.value"
|
|
:questions="client.questions.value"
|
|
:running="running"
|
|
:queued="client.queued.value"
|
|
:search-files="client.searchFiles"
|
|
:upload-image="client.uploadImage"
|
|
:connection="client.connection.value"
|
|
:activity="client.activity.value"
|
|
:sending="client.isSending.value"
|
|
:load-dir="client.listDir"
|
|
:read-file="client.readFileContent"
|
|
:changes-by-path="client.changesByPath.value"
|
|
:file-reload-key="client.activeSessionId.value"
|
|
:session-loading="client.sessionLoading.value"
|
|
:compaction="client.compaction.value"
|
|
:workspace-name="client.visibleWorkspace.value?.name"
|
|
@submit="handleSubmit($event)"
|
|
@steer="client.steerPrompt($event.text, $event.attachments)"
|
|
@approval="(approvalId, response) => client.respondApproval(approvalId, response)"
|
|
@cancel-task="client.cancelTask($event)"
|
|
@answer="(questionId, response) => client.respondQuestion(questionId, response)"
|
|
@dismiss="(questionId) => client.dismissQuestion(questionId)"
|
|
@command="handleCommand"
|
|
@interrupt="client.abortCurrentPrompt()"
|
|
@unqueue="handleUnqueue"
|
|
@edit-queued="handleEditQueued"
|
|
@set-permission="client.setPermission($event)"
|
|
@set-thinking="client.setThinking($event)"
|
|
@toggle-plan="client.togglePlanMode()"
|
|
@compact="client.compact()"
|
|
@pick-model="openModelPicker()"
|
|
@select-model="client.setModel($event)"
|
|
@open-file="openFilePreview($event)"
|
|
@open-media="openMediaPreview($event)"
|
|
@open-thinking="openThinkingPanel($event)"
|
|
@open-compaction="openCompactionPanel($event)"
|
|
/>
|
|
|
|
<!-- Multi-workspace selection placeholder -->
|
|
<div v-else class="coming-soon">
|
|
<span class="cs-icon">🚧</span>
|
|
<span class="cs-text">{{ t('app.comingSoon') }}</span>
|
|
</div>
|
|
|
|
<ResizeHandle
|
|
v-if="sidePanelVisible && !isMobile"
|
|
:storage-key="PREVIEW_WIDTH_KEY"
|
|
:default-width="previewDefaultWidth"
|
|
:min="PREVIEW_MIN"
|
|
:max="previewMaxWidth"
|
|
reverse
|
|
:aria-label="t('layout.resizePreviewAria')"
|
|
@update:width="previewWidth = $event"
|
|
@update:dragging="panelDragging = $event"
|
|
/>
|
|
|
|
<!-- Desktop: the aside is a PERMANENT grid column whose width transitions
|
|
0 ↔ var(--preview-w) — opening genuinely squeezes the chat column over
|
|
(one animation, no slide-over hacks). Mobile mounts only when open
|
|
(full-screen overlay). Content stays v-if'd, so a closed panel is a
|
|
zero-width empty shell. -->
|
|
<aside
|
|
v-if="!isMobile || sidePanelVisible"
|
|
class="global-preview"
|
|
:class="{ open: sidePanelVisible, mobile: isMobile, 'no-anim': panelDragging }"
|
|
>
|
|
<ThinkingPanel
|
|
v-if="thinkingVisible"
|
|
:text="thinkingPanelText ?? ''"
|
|
@close="closeThinkingPanel"
|
|
/>
|
|
<ThinkingPanel
|
|
v-else-if="compactionPanelVisible"
|
|
:text="compactionPanelText ?? ''"
|
|
:subtitle="t('conversation.summaryTitle')"
|
|
@close="closeCompactionPanel"
|
|
/>
|
|
<FilePreview
|
|
v-else-if="previewVisible"
|
|
:file="previewFile"
|
|
:loading="previewLoading"
|
|
:error="previewError"
|
|
:line="previewTarget?.line"
|
|
:download-url="previewDownloadUrl"
|
|
closable
|
|
:external-actions="previewExternalActions"
|
|
@close="closeFilePreview"
|
|
@open-external="openPreviewInEditor"
|
|
@reveal="revealPreviewFile"
|
|
/>
|
|
</aside>
|
|
|
|
<!-- Model Picker overlay -->
|
|
<ModelPicker
|
|
v-if="showModelPicker"
|
|
:models="client.models.value"
|
|
:current="client.status.value.modelId"
|
|
:loading="modelsLoading"
|
|
:unavailable="modelsUnavailable"
|
|
@select="handleSelectModel($event)"
|
|
@close="showModelPicker = false"
|
|
/>
|
|
|
|
<!-- Provider Manager overlay -->
|
|
<ProviderManager
|
|
v-if="showProviders"
|
|
:providers="client.providers.value"
|
|
:loading="providersLoading"
|
|
:unavailable="providersUnavailable"
|
|
@add="handleAddProvider($event)"
|
|
@refresh="handleRefreshProvider($event)"
|
|
@delete="handleDeleteProvider($event)"
|
|
@open-login="() => { showProviders = false; openLogin(); }"
|
|
@close="showProviders = false"
|
|
/>
|
|
|
|
<!-- Login Dialog overlay -->
|
|
<LoginDialog
|
|
v-if="showLogin"
|
|
:on-start-o-auth-login="handleStartOAuthLogin"
|
|
:on-poll-o-auth-login="handlePollOAuthLogin"
|
|
:on-cancel-o-auth-login="handleCancelOAuthLogin"
|
|
@success="handleLoginSuccess"
|
|
@close="showLogin = false"
|
|
/>
|
|
|
|
<!-- New Session Dialog overlay (fallback cwd-typing path) -->
|
|
<NewSessionDialog
|
|
v-if="showNewSession"
|
|
:recent-cwds="client.recentCwds.value"
|
|
@create="({ cwd, title }) => { showNewSession = false; void client.createSession(cwd, { title }); }"
|
|
@close="showNewSession = false"
|
|
/>
|
|
|
|
<!-- Sessions browser overlay (/sessions) — client-side list, click to switch -->
|
|
<SessionsDialog
|
|
v-if="showSessions"
|
|
:sessions="client.sessions.value"
|
|
:workspace-groups="client.workspaceGroups.value"
|
|
:attention-by-session="client.attentionBySession.value"
|
|
:active-id="client.activeSessionId.value"
|
|
@select="(id) => { void client.selectSession(id); showSessions = false; }"
|
|
@close="showSessions = false"
|
|
/>
|
|
|
|
<!-- Status panel overlay (/status) — renders current client state, no daemon call -->
|
|
<StatusPanel
|
|
v-if="showStatusPanel"
|
|
:status="client.status.value"
|
|
:thinking="client.thinking.value"
|
|
:plan-mode="client.planMode.value"
|
|
:cost-usd="client.sessionCost.value"
|
|
@close="showStatusPanel = false"
|
|
/>
|
|
|
|
<!-- Add Workspace overlay (daemon folder browser + paste-path fallback) -->
|
|
<AddWorkspaceDialog
|
|
v-if="showAddWorkspace"
|
|
:browse-fs="client.browseFs"
|
|
:get-fs-home="client.getFsHome"
|
|
@add="(root) => { showAddWorkspace = false; void client.addWorkspaceByPath(root); }"
|
|
@close="showAddWorkspace = false"
|
|
/>
|
|
|
|
<!-- Onboarding banner: shown when daemon has no auth configured -->
|
|
<div v-if="!authReady" class="auth-banner">
|
|
<div class="auth-banner-inner">
|
|
<div class="auth-banner-icon">
|
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="var(--blue)" stroke-width="1.5">
|
|
<circle cx="10" cy="10" r="8"/>
|
|
<line x1="10" y1="6" x2="10" y2="10"/>
|
|
<circle cx="10" cy="13" r="1" fill="var(--blue)"/>
|
|
</svg>
|
|
</div>
|
|
<span class="auth-banner-msg">{{ t('app.authBannerMessage') }}</span>
|
|
<button class="auth-banner-btn" @click="openLogin">{{ t('app.authBannerLogin') }}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Global connecting splash on first load (until the daemon round-trips) -->
|
|
<Transition name="gload-fade">
|
|
<GlobalLoading v-if="!client.initialized.value" />
|
|
</Transition>
|
|
|
|
<!-- First-run onboarding overlay (theme / language / welcome greeting) -->
|
|
<Onboarding
|
|
v-if="showOnboarding"
|
|
:theme="client.theme.value"
|
|
:accent="client.accent.value"
|
|
@set-theme="client.setTheme($event)"
|
|
@set-accent="client.setAccent($event)"
|
|
@complete="completeOnboarding"
|
|
@skip="completeOnboarding"
|
|
/>
|
|
|
|
<!-- Floating warnings / agent errors (e.g. a 403 from the model provider) -->
|
|
<WarningToasts :warnings="client.warnings.value" @dismiss="client.dismissWarning" />
|
|
|
|
<!-- KAP/daemon debug panel (opt-in, ?debug=1) -->
|
|
<DebugPanel v-if="debugEnabled" />
|
|
|
|
<!-- Mobile switcher bottom-sheet: workspace groups + sessions (mirrors the
|
|
desktop sidebar) -->
|
|
<MobileSwitcherSheet
|
|
v-if="isMobile"
|
|
v-model="showMobileSwitcher"
|
|
:groups="client.workspaceGroups.value"
|
|
:active-workspace-id="client.activeWorkspaceId.value"
|
|
:active-id="client.activeSessionId.value"
|
|
:attention-by-session="client.attentionBySession.value"
|
|
:attention-by-workspace="client.attentionByWorkspace.value"
|
|
@select="client.selectSession($event)"
|
|
@create-in-workspace="handleCreateSessionInWorkspace($event)"
|
|
@add-workspace="showAddWorkspace = true"
|
|
@rename="(id, title) => client.renameSession(id, title)"
|
|
@delete="(id) => client.deleteSession(id)"
|
|
@delete-workspace="(id) => client.deleteWorkspace(id)"
|
|
/>
|
|
|
|
<!-- Mobile settings bottom-sheet: session controls + app prefs + auth -->
|
|
<MobileSettingsSheet
|
|
v-if="isMobile"
|
|
v-model="showMobileSettings"
|
|
:status="client.status.value"
|
|
:thinking="client.thinking.value"
|
|
:plan-mode="client.planMode.value"
|
|
:theme="client.theme.value"
|
|
:color-scheme="client.colorScheme.value"
|
|
:accent="client.accent.value"
|
|
:auth-ready="client.authReady.value"
|
|
@pick-model="openModelPicker()"
|
|
@set-thinking="client.setThinking($event)"
|
|
@toggle-plan="client.togglePlanMode()"
|
|
@set-permission="client.setPermission($event)"
|
|
@set-theme="client.setTheme($event)"
|
|
@set-color-scheme="client.setColorScheme($event)"
|
|
@set-accent="client.setAccent($event)"
|
|
@login="openLogin"
|
|
@logout="client.logout"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* Global connecting splash fade-out (only the leave matters; it mounts instantly). */
|
|
.gload-fade-leave-active { transition: opacity 0.28s ease; }
|
|
.gload-fade-leave-to { opacity: 0; }
|
|
|
|
.app {
|
|
--side-w: 248px;
|
|
--preview-w: 460px;
|
|
height: 100vh;
|
|
display: grid;
|
|
/* sidebar (rail + resizable session column) | 0-width handle | conversation.
|
|
The 4px ResizeHandle overflows its zero-width track via negative margins so
|
|
the whole strip is grabbable without consuming layout space. */
|
|
/* The right-panel track is PERMANENT (auto = follows the aside's width, 0
|
|
when closed) — opening animates the aside's width, so the conversation
|
|
column is squeezed over smoothly instead of snapping to a new template. */
|
|
grid-template-columns: var(--side-w) 0 minmax(0, 1fr) 0 auto;
|
|
background: var(--bg);
|
|
color: var(--ink);
|
|
border-top: 2px solid var(--ink);
|
|
overflow: hidden;
|
|
box-sizing: border-box;
|
|
}
|
|
/* Grid children must be allowed to shrink below content height so that only
|
|
the inner scroll containers (.panes / .sessions) scroll — otherwise the
|
|
whole .app overflows and the page (incl. sidebar) scrolls together. */
|
|
.app > * {
|
|
min-height: 0;
|
|
min-width: 0;
|
|
}
|
|
|
|
/* Mobile single-column shell: slim top bar (auto) over the full-width
|
|
conversation pane (1fr). No rail, no session column, no resize handle. */
|
|
.app.mobile {
|
|
grid-template-columns: 1fr;
|
|
grid-template-rows: auto 1fr;
|
|
}
|
|
|
|
/* The right-side panel column: a permanent grid item whose width animates
|
|
0 ↔ var(--preview-w). The CONTENT keeps a fixed width (and carries the
|
|
left hairline) so it clips during the transition instead of reflowing. */
|
|
.global-preview {
|
|
grid-column: 5;
|
|
min-width: 0;
|
|
min-height: 0;
|
|
width: 0;
|
|
background: var(--bg);
|
|
overflow: hidden;
|
|
transition: width 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
.global-preview.open {
|
|
width: var(--preview-w);
|
|
}
|
|
/* While dragging the resize handle, follow the pointer 1:1. */
|
|
.global-preview.no-anim {
|
|
transition: none;
|
|
}
|
|
.global-preview:not(.mobile) > * {
|
|
width: var(--preview-w);
|
|
height: 100%;
|
|
box-sizing: border-box;
|
|
border-left: 1px solid var(--line);
|
|
}
|
|
.global-preview.mobile {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 80;
|
|
width: auto;
|
|
transition: none;
|
|
border-top: 2px solid var(--ink);
|
|
}
|
|
|
|
/* Auth onboarding banner */
|
|
.auth-banner {
|
|
position: fixed;
|
|
top: 0;
|
|
left: var(--side-w); /* sidebar width (52 rail + resizable session column) */
|
|
right: 0;
|
|
z-index: 50;
|
|
background: var(--soft);
|
|
border-bottom: 1px solid var(--bd);
|
|
}
|
|
/* Mobile: the banner spans the full width (no sidebar to clear). */
|
|
.app.mobile .auth-banner { left: 0; }
|
|
.auth-banner-inner {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 8px 16px;
|
|
font-family: var(--mono);
|
|
font-size: 14px;
|
|
}
|
|
.auth-banner-icon { display: flex; align-items: center; flex: none; }
|
|
.auth-banner-msg { flex: 1; color: var(--text); }
|
|
.auth-banner-btn {
|
|
background: var(--blue);
|
|
border: none;
|
|
border-radius: 3px;
|
|
font-family: var(--mono);
|
|
font-size: 12px;
|
|
padding: 4px 14px;
|
|
color: #fff;
|
|
cursor: pointer;
|
|
flex: none;
|
|
}
|
|
.auth-banner-btn:hover { background: var(--blue2); }
|
|
|
|
/* Multi-workspace selection placeholder */
|
|
.coming-soon {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 12px;
|
|
height: 100%;
|
|
color: var(--muted);
|
|
font-family: var(--mono);
|
|
}
|
|
.cs-icon { font-size: 32px; }
|
|
.cs-text { font-size: 14px; }
|
|
</style>
|
|
|
|
<style>
|
|
/* Right-side panel headers (ThinkingPanel / FilePreview) track the TabBar
|
|
height per theme: 32px terminal (the components' var fallback), 40px
|
|
modern/kimi. */
|
|
html[data-theme="modern"],
|
|
html[data-theme="kimi"] {
|
|
--panel-head-h: 40px;
|
|
}
|
|
</style>
|