kimi-code/apps/kimi-web/src/App.vue
qer f5a7f21c68 feat(web): land P3 — goal / swarm / subagent + terminal + view split
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.
2026-06-13 22:53:20 +08:00

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>