mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-23 16:14:46 +00:00
* chore(web): drop the /help, /model, /provider, and /permission slash commands * chore: drop the changeset
1274 lines
48 KiB
Vue
1274 lines
48 KiB
Vue
<!-- apps/kimi-web/src/App.vue -->
|
||
<script setup lang="ts">
|
||
import { computed, nextTick, onMounted, onUnmounted, 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/chat/ConversationPane.vue';
|
||
import FilePreview from './components/FilePreview.vue';
|
||
import ThinkingPanel from './components/chat/ThinkingPanel.vue';
|
||
import AgentDetailPanel from './components/chat/AgentDetailPanel.vue';
|
||
import ToolDiffPanel from './components/chat/ToolDiffPanel.vue';
|
||
import SideChatPanel from './components/chat/SideChatPanel.vue';
|
||
import DiffView from './components/chat/DiffView.vue';
|
||
import ModelPicker from './components/settings/ModelPicker.vue';
|
||
import ProviderManager from './components/settings/ProviderManager.vue';
|
||
import LoginDialog from './components/dialogs/LoginDialog.vue';
|
||
import SettingsDialog from './components/settings/SettingsDialog.vue';
|
||
import AddWorkspaceDialog from './components/dialogs/AddWorkspaceDialog.vue';
|
||
import ConfirmDialogHost from './components/dialogs/ConfirmDialogHost.vue';
|
||
import StatusPanel from './components/chat/StatusPanel.vue';
|
||
import WarningToasts from './components/WarningToasts.vue';
|
||
import MobileTopBar from './components/mobile/MobileTopBar.vue';
|
||
import MobileSwitcherSheet from './components/mobile/MobileSwitcherSheet.vue';
|
||
import MobileSettingsSheet from './components/mobile/MobileSettingsSheet.vue';
|
||
import Onboarding from './components/settings/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 { 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 { openDialogCount } from './composables/dialogStack';
|
||
import type { SwarmMember } from './composables/swarmGroups';
|
||
import ServerAuthDialog from './components/ServerAuthDialog.vue';
|
||
import { initServerAuth, onAuthRequired } from './api/daemon/serverAuth';
|
||
import type { AppConfig, ThinkingLevel } from './api/types';
|
||
import { coerceThinkingForModel, commitLevel, segmentsFor } from './lib/modelThinking';
|
||
import { stripSkillPrefix } from './lib/slashCommands';
|
||
import Button from './components/ui/Button.vue';
|
||
import IconButton from './components/ui/IconButton.vue';
|
||
import Icon from './components/ui/Icon.vue';
|
||
import InternalBuildBanner from './components/InternalBuildBanner.vue';
|
||
import { isMacosDesktop } from './lib/desktopFlag';
|
||
|
||
// Hydrate the server-transport credential (fragment token or localStorage)
|
||
// BEFORE the client connects, so the first REST/WS calls already carry it.
|
||
initServerAuth();
|
||
// Stays false until the server actually rejects us with 401/40101. Starting
|
||
// from "no credential ⇒ prompt" flashed the token dialog for a frame in
|
||
// `--dangerous-bypass-auth` mode, before /meta had advertised the bypass.
|
||
const authRequired = ref(false);
|
||
let offAuthRequired: (() => void) | null = null;
|
||
|
||
const client = useKimiWebClient();
|
||
// When the server runs with `--dangerous-bypass-auth`, `/meta` advertises it
|
||
// and we skip the token prompt entirely — there is no credential to enter.
|
||
const showServerAuth = computed(
|
||
() => !client.dangerousBypassAuth.value && authRequired.value,
|
||
);
|
||
provide('resolveImage', client.resolveImageUrl);
|
||
// Live swarm member roster for the inline AgentSwarm tool card. Sourced from the
|
||
// AppTask store so the card shows each subagent's live phase; on refresh the
|
||
// tasks are gone and the card falls back to the parsed tool result. Includes
|
||
// single-member "swarms" (e.g. AgentSwarm with one resume_agent_ids entry),
|
||
// which buildSwarmGroups filters out for the badge counter.
|
||
provide(
|
||
'resolveSwarmMembers',
|
||
(toolCallId: string): SwarmMember[] => client.swarmMembersByToolCallId.value.get(toolCallId) ?? [],
|
||
);
|
||
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. Falls back to desktop when matchMedia is unavailable.
|
||
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,
|
||
);
|
||
|
||
// running: true when activity is not idle
|
||
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 authLogoRef = ref<SVGSVGElement | null>(null);
|
||
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. Prefixes an animated
|
||
// spinner while the agent is running so activity is visible at a glance.
|
||
usePageTitle({ running, showAuthGate });
|
||
|
||
// The /thinking slash command has no popover anchor, so it steps to the next
|
||
// segment for the active model (effort models cycle through their declared
|
||
// levels; boolean models flip on/off; unsupported stays off).
|
||
function nextThinkingLevel(current: ThinkingLevel): ThinkingLevel {
|
||
// Identity is the model id — display/model names can collide across providers.
|
||
const model = client.models.value.find((m) => m.id === client.status.value.modelId);
|
||
const segs = segmentsFor(model);
|
||
// Coerce the stored level against the active model before indexing, so a
|
||
// stale value (e.g. 'on' from a boolean model) doesn't resolve to index -1
|
||
// and jump to 'off' instead of advancing from the model's default effort.
|
||
const coerced = coerceThinkingForModel(model, current);
|
||
const idx = segs.indexOf(coerced);
|
||
const next = segs[(idx + 1) % segs.length] ?? segs[0] ?? 'off';
|
||
return commitLevel(model, next);
|
||
}
|
||
|
||
// First-run onboarding (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(() => {
|
||
// Register the 401 listener before the first requests go out, so a token
|
||
// rejection during the initial load() can never be missed.
|
||
offAuthRequired = onAuthRequired(() => {
|
||
authRequired.value = true;
|
||
// The server now demands a token, so any cached "bypass" state from a
|
||
// previous mode is stale — drop it so the token prompt can show.
|
||
client.clearDangerousBypassAuth();
|
||
});
|
||
void client.load();
|
||
loadSidebarCollapsed();
|
||
// Capture-phase so Escape closes the side detail layer BEFORE the
|
||
// conversation pane's bubble-phase handler interrupts a running prompt.
|
||
document.addEventListener('keydown', onGlobalKeydown, true);
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
document.removeEventListener('keydown', onGlobalKeydown, true);
|
||
if (offAuthRequired !== null) {
|
||
offAuthRequired();
|
||
offAuthRequired = null;
|
||
}
|
||
});
|
||
|
||
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
|
||
// alone so the dialog can close itself instead of the panel behind it.
|
||
if (anyOverlayOpen.value) return;
|
||
if (closeOpenSidePanel()) {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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.
|
||
// ---------------------------------------------------------------------------
|
||
const detailTarget = ref<DetailTarget | null>(null);
|
||
|
||
// True for one frame while the active session changes: suppresses the right
|
||
// panel's width transition so a restored panel snaps to its width instead of
|
||
// animating open from zero.
|
||
const panelSwitching = ref(false);
|
||
watch(client.activeSessionId, () => {
|
||
panelSwitching.value = true;
|
||
void nextTick(() => { panelSwitching.value = false; });
|
||
});
|
||
|
||
const {
|
||
previewTarget,
|
||
previewFile,
|
||
previewLoading,
|
||
previewError,
|
||
previewDownloadUrl,
|
||
previewExternalActions,
|
||
openFilePreview,
|
||
openMediaPreview,
|
||
closeFilePreview,
|
||
openPreviewInEditor,
|
||
revealPreviewFile,
|
||
} = useFilePreview({ client, detailTarget });
|
||
|
||
// True while the right-side slot is actually occupied, so the sidebar reserves
|
||
// room for it and the conversation can never be squeezed. Keyed off detailTarget
|
||
// (the real occupant) rather than previewTarget, which can stay set after the
|
||
// panel is hidden.
|
||
const previewOpen = computed(() => detailTarget.value !== null);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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,
|
||
SIDEBAR_DEFAULT,
|
||
SIDEBAR_MIN,
|
||
sidebarMax,
|
||
sessionColWidth,
|
||
sidebarCollapsed,
|
||
sidebarDragging,
|
||
sideWidth,
|
||
loadSidebarCollapsed,
|
||
toggleSidebarCollapse,
|
||
} = useSidebarLayout({ previewOpen });
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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 {
|
||
PREVIEW_WIDTH_KEY,
|
||
PREVIEW_MIN,
|
||
previewDefaultWidth,
|
||
previewMax,
|
||
previewWidth,
|
||
previewPanelWidth,
|
||
thinkingPanelText,
|
||
thinkingVisible,
|
||
openThinkingPanel,
|
||
closeThinkingPanel,
|
||
compactionPanelText,
|
||
compactionPanelVisible,
|
||
openCompactionPanel,
|
||
closeCompactionPanel,
|
||
agentPanelMember,
|
||
openAgentPanel,
|
||
closeAgentPanel,
|
||
toolDiffTarget,
|
||
openToolDiff,
|
||
closeToolDiff,
|
||
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);
|
||
|
||
// Dialog visibility refs
|
||
const showModelPicker = ref(false);
|
||
const showProviders = ref(false);
|
||
|
||
const showLogin = ref(false);
|
||
const showAddWorkspace = ref(false);
|
||
const showStatusPanel = ref(false);
|
||
const showSettings = ref(false);
|
||
|
||
type SubmitPayload = {
|
||
text: string;
|
||
attachments: { fileId: string; kind: 'image' | 'video' }[];
|
||
};
|
||
const pendingWorkspaceSubmit = ref<SubmitPayload | null>(null);
|
||
// Inline error shown inside the add-workspace picker after the daemon rejects
|
||
// a path. Kept separate from the global toast so the feedback is visible above
|
||
// the picker's backdrop and persists until the user retries or closes.
|
||
const addWorkspaceError = ref<string | null>(null);
|
||
|
||
// Any of these modal/overlay layers, when open, owns Escape. The global
|
||
// capture-phase handler must NOT close a background side panel out from under an
|
||
// open dialog — otherwise Escape dismisses the panel behind the dialog and the
|
||
// dialog's own Escape handler never fires. New top-level dialogs go here too.
|
||
const anyOverlayOpen = computed<boolean>(
|
||
() =>
|
||
openDialogCount.value > 0 ||
|
||
showModelPicker.value ||
|
||
showProviders.value ||
|
||
showLogin.value ||
|
||
showAddWorkspace.value ||
|
||
showStatusPanel.value ||
|
||
showSettings.value ||
|
||
showOnboarding.value ||
|
||
showMobileSwitcher.value ||
|
||
showMobileSettings.value,
|
||
);
|
||
|
||
// Loading state for model/provider fetches
|
||
const modelsLoading = ref(false);
|
||
const modelsUnavailable = ref(false);
|
||
const providersLoading = ref(false);
|
||
const providersUnavailable = ref(false);
|
||
const configSaving = ref(false);
|
||
|
||
async function openModelPicker(): Promise<void> {
|
||
modelsLoading.value = true;
|
||
modelsUnavailable.value = false;
|
||
showModelPicker.value = true;
|
||
try {
|
||
await client.refreshOAuthProviderModels();
|
||
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;
|
||
// Same semantics as the composer dropdown rows: the overlay is just the
|
||
// "more models" continuation of the same flow, so it must also bump the
|
||
// global default (see handleComposerSelectModel).
|
||
await handleComposerSelectModel(modelId);
|
||
}
|
||
|
||
async function handleComposerSelectModel(modelId: string): Promise<void> {
|
||
// Primary action: switch the active session's model via POST /sessions/{id}/profile
|
||
// (same as the model picker overlay). Awaited so the model pill reflects the
|
||
// result and failures surface. In the onboarding draft this just stores the
|
||
// pick for the first session.
|
||
const switched = await client.setModel(modelId);
|
||
|
||
// Side effect: also bump the daemon-wide default model via POST /config so
|
||
// new sessions inherit the choice. Fire-and-forget — it must not block the UI
|
||
// or mask the session switch. Only after a confirmed switch (a stale/invalid
|
||
// alias must not become the global default), and skip when it already
|
||
// matches the default.
|
||
if (switched && modelId !== client.defaultModel.value) {
|
||
void client.updateConfig({ defaultModel: 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);
|
||
}
|
||
|
||
async function handleUpdateConfig(patch: Partial<AppConfig>): Promise<void> {
|
||
configSaving.value = true;
|
||
try {
|
||
const saved = await client.updateConfig(patch);
|
||
if (saved) {
|
||
await client.checkAuth();
|
||
}
|
||
} finally {
|
||
configSaving.value = false;
|
||
}
|
||
}
|
||
|
||
// 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();
|
||
}
|
||
|
||
// Edit + resend the last user message: undo the latest exchange on the daemon,
|
||
// then drop that message's text back into the composer for editing.
|
||
async function handleEditMessage(payload: {
|
||
text: string;
|
||
images?: { url: string; alt?: string; kind: 'image' | 'video'; fileId?: string }[];
|
||
}): Promise<void> {
|
||
await client.undo(1);
|
||
await nextTick();
|
||
conversationPaneRef.value?.loadComposerForEdit(payload.text, payload.images);
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
// `/swarm` toggles swarm mode; `/swarm on|off` sets it; `/swarm <task>` enables
|
||
// swarm and runs the task right away (TUI parity).
|
||
if (cmd === '/swarm' || cmd.startsWith('/swarm ')) {
|
||
const arg = cmd.slice('/swarm'.length).trim();
|
||
if (arg === 'on') client.setSwarmMode(true);
|
||
else if (arg === 'off') client.setSwarmMode(false);
|
||
else if (arg) { client.setSwarmMode(true); void client.sendPrompt(arg); }
|
||
else void client.toggleSwarmMode();
|
||
return;
|
||
}
|
||
// `/goal <objective>` creates a goal (and submits it); `/goal pause|resume|cancel`
|
||
// controls the active one; bare `/goal` toggles goal mode for the next message.
|
||
if (cmd === '/goal' || cmd.startsWith('/goal ')) {
|
||
const arg = cmd.slice('/goal'.length).trim();
|
||
if (arg === 'pause' || arg === 'resume' || arg === 'cancel') client.controlGoal(arg);
|
||
else if (arg) void client.createGoal(arg);
|
||
else client.toggleGoalMode();
|
||
return;
|
||
}
|
||
// `/btw <question>` opens (creating if needed) the side chat and asks it; bare
|
||
// `/btw` toggles the side-chat tab for the active session.
|
||
if (cmd === '/btw' || cmd.startsWith('/btw ')) {
|
||
const arg = cmd.slice('/btw'.length).trim();
|
||
if (!arg && client.sideChatVisible.value) {
|
||
// Use the detail-layer close so detailTarget is cleared too; the bare
|
||
// client.closeSideChat() only hides the panel and leaves detailTarget set.
|
||
closeSideChat();
|
||
} else {
|
||
void openSideChatTab(arg || undefined);
|
||
}
|
||
return;
|
||
}
|
||
switch (cmd) {
|
||
// `/new` and `/clear` are aliases: both open the onboarding composer. The
|
||
// session is only created when the user sends the first message.
|
||
case '/new':
|
||
case '/clear':
|
||
handleCreateSession();
|
||
break;
|
||
case '/fork':
|
||
void client.forkSession();
|
||
break;
|
||
case '/undo':
|
||
void client.undo();
|
||
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 '/status':
|
||
showStatusPanel.value = true;
|
||
break;
|
||
case '/login':
|
||
openLogin();
|
||
break;
|
||
default: {
|
||
// Not a built-in command → treat it as a session skill activation
|
||
// (the user picked `/skill:<skill>` from the menu, or typed
|
||
// `/<skill> args`). Strip the `skill:` display prefix — the REST API
|
||
// takes the bare skill name. The daemon answers an unknown name with
|
||
// skill.not_found, surfaced as a warning, so a stray slash is harmless.
|
||
// With no active session, create one first (same path as the first
|
||
// prompt) so the activation isn't silently dropped on the new-session
|
||
// screen.
|
||
const space = cmd.indexOf(' ');
|
||
const name = stripSkillPrefix((space === -1 ? cmd : cmd.slice(0, space)).slice(1));
|
||
const args = space === -1 ? undefined : cmd.slice(space + 1).trim() || undefined;
|
||
if (!name) break;
|
||
if (!client.activeSessionId.value && client.activeWorkspaceId.value) {
|
||
void client.startSessionAndActivateSkill(client.activeWorkspaceId.value, name, args);
|
||
} else {
|
||
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);
|
||
}
|
||
|
||
function handleReorderQueue(payload: { from: number; to: number }): void {
|
||
client.reorderQueue(payload.from, payload.to);
|
||
}
|
||
|
||
async function handleSubmit(payload: SubmitPayload): Promise<void> {
|
||
const wsId = client.activeWorkspaceId.value;
|
||
if (!client.activeSessionId.value && wsId) {
|
||
await client.startSessionAndSendPrompt(wsId, payload.text, payload.attachments);
|
||
return;
|
||
}
|
||
if (!client.activeSessionId.value && !wsId) {
|
||
pendingWorkspaceSubmit.value = payload;
|
||
showAddWorkspace.value = true;
|
||
return;
|
||
}
|
||
void client.sendPrompt(payload.text, payload.attachments);
|
||
}
|
||
|
||
async function handleAddWorkspace(root: string): Promise<void> {
|
||
addWorkspaceError.value = null;
|
||
const added = await client.addWorkspaceByPath(root);
|
||
// Keep the picker open (and the pending submission intact) when the daemon
|
||
// rejects the path so the user can retry with a valid one. The error is shown
|
||
// inline in the picker. Closing via Escape goes through handleCloseAddWorkspace,
|
||
// which drops the pending prompt.
|
||
if (!added) {
|
||
addWorkspaceError.value = t('workspace.addFailed');
|
||
return;
|
||
}
|
||
showAddWorkspace.value = false;
|
||
const pending = pendingWorkspaceSubmit.value;
|
||
pendingWorkspaceSubmit.value = null;
|
||
const wsId = client.activeWorkspaceId.value;
|
||
if (pending && wsId) {
|
||
await client.startSessionAndSendPrompt(wsId, pending.text, pending.attachments);
|
||
}
|
||
}
|
||
|
||
function handleCloseAddWorkspace(): void {
|
||
pendingWorkspaceSubmit.value = null;
|
||
addWorkspaceError.value = null;
|
||
showAddWorkspace.value = false;
|
||
}
|
||
|
||
function focusComposerAfterDraft(): void {
|
||
void nextTick(() => {
|
||
conversationPaneRef.value?.focusComposer();
|
||
});
|
||
}
|
||
|
||
// 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 {
|
||
client.clearActiveSession();
|
||
}
|
||
focusComposerAfterDraft();
|
||
}
|
||
|
||
// 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);
|
||
focusComposerAfterDraft();
|
||
}
|
||
|
||
// Chat header: open a GitHub PR in a new tab.
|
||
function openPr(url: string): void {
|
||
if (url) window.open(url, '_blank', 'noopener');
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="app-shell">
|
||
<ServerAuthDialog v-if="showServerAuth" />
|
||
<section v-if="showAuthGate" class="auth-page">
|
||
<div class="auth-page-inner">
|
||
<svg ref="authLogoRef" class="auth-page-logo ch-logo" viewBox="0 0 32 22" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Kimi Code" @mousedown.prevent @click="blinkAuthLogo">
|
||
<defs>
|
||
<mask id="authKimiEyes" maskUnits="userSpaceOnUse">
|
||
<rect x="0" y="0" width="32" height="22" fill="#fff" />
|
||
<g class="ch-eyes" fill="#000">
|
||
<rect class="ch-eye" x="11.8" y="7" width="2.8" height="8" rx="1.4" />
|
||
<rect class="ch-eye" x="17.4" y="7" width="2.8" height="8" rx="1.4" />
|
||
</g>
|
||
</mask>
|
||
</defs>
|
||
<rect x="1" y="1" width="30" height="20" rx="6" fill="var(--logo)" mask="url(#authKimiEyes)" />
|
||
</svg>
|
||
<div class="auth-page-copy">
|
||
<h1>{{ t('app.authPageTitle') }}</h1>
|
||
<p>{{ t('app.authPageMessage') }}</p>
|
||
</div>
|
||
<Button class="auth-page-btn" variant="primary" @click="openLogin">
|
||
<Icon name="log-in" size="md" />
|
||
<span>{{ t('app.authPageLogin') }}</span>
|
||
</Button>
|
||
</div>
|
||
</section>
|
||
<div
|
||
v-else
|
||
class="app"
|
||
:class="{
|
||
mobile: isMobile,
|
||
'sidebar-collapsed': sidebarCollapsed && !isMobile,
|
||
'macos-desktop': isMacosDesktop,
|
||
}"
|
||
:style="{ '--preview-w': previewPanelWidth + 'px' }"
|
||
>
|
||
<!-- Desktop navigation: workspace rail + resizable session column. -->
|
||
<template v-if="!isMobile">
|
||
<Sidebar
|
||
:collapsed="sidebarCollapsed"
|
||
:dragging="sidebarDragging"
|
||
:col-width="sideWidth"
|
||
: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"
|
||
:pending-by-session="client.pendingBySession.value"
|
||
:unread-by-session="client.unreadBySession.value"
|
||
:workspace-sort-mode="client.workspaceSortMode.value"
|
||
:backend="client.backend.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)"
|
||
@archive="(id) => client.archiveSession(id)"
|
||
@fork="(id) => client.forkSession(id)"
|
||
@rename-workspace="(id, name) => client.renameWorkspace(id, name)"
|
||
@delete-workspace="(id) => client.deleteWorkspace(id)"
|
||
@reorder-workspaces="client.reorderWorkspaces($event)"
|
||
@set-workspace-sort-mode="client.setWorkspaceSortMode($event)"
|
||
@load-more-sessions="(id) => void client.loadMoreSessions(id)"
|
||
@load-all-sessions="void client.loadAllSessions()"
|
||
@open-settings="showSettings = true"
|
||
@collapse="toggleSidebarCollapse"
|
||
/>
|
||
<ResizeHandle
|
||
v-show="!sidebarCollapsed"
|
||
class="side-handle"
|
||
:storage-key="SIDEBAR_WIDTH_KEY"
|
||
:default-width="SIDEBAR_DEFAULT"
|
||
:min="SIDEBAR_MIN"
|
||
:max="sidebarMax"
|
||
@update:width="sessionColWidth = $event"
|
||
@update:dragging="sidebarDragging = $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
|
||
ref="conversationPaneRef"
|
||
:mobile="isMobile"
|
||
:turns="client.turns.value"
|
||
:session-id="client.activeSessionId.value"
|
||
:approvals="client.pendingApprovals.value"
|
||
:changes="client.changes.value"
|
||
:git-info="client.gitInfo.value"
|
||
:tasks="client.tasks.value"
|
||
:todos="client.todos.value"
|
||
:goal="client.goal.value"
|
||
:activation-badges="client.activationBadges.value"
|
||
:status="client.status.value"
|
||
:thinking="client.thinking.value"
|
||
:plan-mode="client.planMode.value"
|
||
:swarm-mode="client.swarmMode.value"
|
||
:goal-mode="client.goalMode.value"
|
||
:models="client.models.value"
|
||
:starred-ids="client.starredModelIds.value"
|
||
:skills="client.skills.value"
|
||
:questions="client.questions.value"
|
||
:pending-question-actions="client.pendingQuestionActions"
|
||
:pending-approval-actions="client.pendingApprovalActions"
|
||
:running="running"
|
||
:queued="client.queued.value"
|
||
:search-files="client.searchFiles"
|
||
:upload-image="client.uploadImage"
|
||
:sending="client.isSending.value"
|
||
:starting="client.isStartingFirstPrompt.value"
|
||
:fast-moon="client.fastMoon.value"
|
||
:file-reload-key="client.activeSessionId.value"
|
||
:session-loading="client.sessionLoading.value"
|
||
:compaction="client.compaction.value"
|
||
:has-more-messages="client.hasMoreMessages.value"
|
||
:loading-more="client.loadingMoreMessages.value"
|
||
:loading-more-error="client.loadMoreMessagesError.value"
|
||
:load-older-messages="client.loadOlderMessages"
|
||
:workspace-name="client.visibleWorkspace.value?.name"
|
||
:workspace-root="client.visibleWorkspace.value?.root ?? client.status.value.cwd"
|
||
:git-diff-stats="client.gitDiffStats.value"
|
||
:workspaces="client.workspacesView.value"
|
||
:active-workspace-id="client.activeWorkspaceId.value"
|
||
:session-title="activeSessionTitle"
|
||
:pr="client.activePullRequest.value"
|
||
:conversation-toc="client.conversationToc.value"
|
||
@open-changes="openDiffDetail()"
|
||
@select-workspace="handleCreateSessionInWorkspace($event)"
|
||
@add-workspace="showAddWorkspace = true"
|
||
@open-pr="openPr"
|
||
@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"
|
||
@reorder-queue="handleReorderQueue"
|
||
@set-permission="client.setPermission($event)"
|
||
@set-thinking="client.setThinking($event)"
|
||
@toggle-plan="client.togglePlanMode()"
|
||
@toggle-swarm="client.toggleSwarmMode()"
|
||
@toggle-goal="client.toggleGoalMode()"
|
||
@create-goal="client.createGoal($event)"
|
||
@control-goal="client.controlGoal($event)"
|
||
@refresh-git-status="client.activeSessionId.value && client.loadGitStatus(client.activeSessionId.value)"
|
||
@rename-session="(id, title) => client.renameSession(id, title)"
|
||
@fork-session="(id) => client.forkSession(id)"
|
||
@archive-session="(id) => client.archiveSession(id)"
|
||
@compact="client.compact()"
|
||
@pick-model="openModelPicker()"
|
||
@select-model="handleComposerSelectModel($event)"
|
||
@open-file="openFilePreview($event)"
|
||
@open-media="openMediaPreview($event)"
|
||
@open-thinking="openThinkingPanel($event)"
|
||
@open-compaction="openCompactionPanel($event)"
|
||
@open-agent="openAgentPanel($event)"
|
||
@open-tool-diff="openToolDiff($event)"
|
||
@edit-message="handleEditMessage"
|
||
/>
|
||
|
||
<!-- Sidebar toggle — floating only when the in-header control can't serve:
|
||
on macOS desktop it's RESIDENT (always rendered beside the traffic
|
||
lights, the sidebar slides underneath and only the glyph swaps, so it
|
||
never moves or flashes); on Windows/web the collapse button lives
|
||
inside the sidebar header, so this floating button only appears while
|
||
COLLAPSED (to re-expand the sidebar). It must come AFTER
|
||
ConversationPane in the DOM: Electron computes the window-drag region
|
||
in tree order (drag rects union, no-drag rects subtract), so a no-drag
|
||
element placed before the ChatHeader drag region would have its hole
|
||
painted back over — making the button an inert drag area. -->
|
||
<IconButton
|
||
v-if="!isMobile && (isMacosDesktop || sidebarCollapsed)"
|
||
class="sidebar-toggle-btn"
|
||
size="sm"
|
||
:label="sidebarCollapsed ? t('sidebar.expandSidebar') : t('sidebar.collapseSidebar')"
|
||
@click="toggleSidebarCollapse"
|
||
>
|
||
<Icon :name="sidebarCollapsed ? 'panel-expand' : 'panel-collapse'" />
|
||
</IconButton>
|
||
|
||
<ResizeHandle
|
||
v-if="sidePanelVisible && !isMobile"
|
||
class="preview-handle"
|
||
:storage-key="PREVIEW_WIDTH_KEY"
|
||
:default-width="previewDefaultWidth"
|
||
:min="PREVIEW_MIN"
|
||
:max="previewMax"
|
||
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 || panelSwitching }"
|
||
role="complementary"
|
||
:aria-label="t('layout.detailPanelAria')"
|
||
:aria-hidden="!sidePanelVisible"
|
||
>
|
||
<ThinkingPanel
|
||
v-if="detailTarget === 'thinking' && thinkingVisible"
|
||
:text="thinkingPanelText ?? ''"
|
||
@close="closeThinkingPanel"
|
||
/>
|
||
<ThinkingPanel
|
||
v-else-if="detailTarget === 'compaction' && compactionPanelVisible"
|
||
:text="compactionPanelText ?? ''"
|
||
:subtitle="t('conversation.summaryTitle')"
|
||
@close="closeCompactionPanel"
|
||
/>
|
||
<AgentDetailPanel
|
||
v-else-if="detailTarget === 'agent' && agentPanelMember"
|
||
:member="agentPanelMember"
|
||
@close="closeAgentPanel"
|
||
/>
|
||
<SideChatPanel
|
||
v-else-if="detailTarget === 'btw' && btwVisible"
|
||
:turns="client.sideChatTurns.value"
|
||
:running="client.sideChatRunning.value"
|
||
:sending="client.sideChatSending.value"
|
||
@send="client.sendSideChatPrompt($event)"
|
||
@close="closeSideChat"
|
||
/>
|
||
<DiffView
|
||
v-else-if="detailTarget === 'diff'"
|
||
:mode="detailDiffMode"
|
||
: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"
|
||
closable
|
||
@open="selectDiffFile"
|
||
@back="detailDiffMode = 'list'; detailDiffPath = null; client.clearFileDiff()"
|
||
@close="closeDiffDetail"
|
||
/>
|
||
<ToolDiffPanel
|
||
v-else-if="detailTarget === 'toolDiff' && toolDiffTarget"
|
||
:target="toolDiffTarget"
|
||
@close="closeToolDiff"
|
||
/>
|
||
<FilePreview
|
||
v-else-if="detailTarget === 'file'"
|
||
:file="previewFile"
|
||
:loading="previewLoading"
|
||
:error="previewError"
|
||
:line="previewTarget?.line"
|
||
:download-url="previewDownloadUrl"
|
||
closable
|
||
:external-actions="previewExternalActions"
|
||
:open-file="openFilePreview"
|
||
@close="closeFilePreview"
|
||
@open-external="openPreviewInEditor"
|
||
@reveal="revealPreviewFile"
|
||
/>
|
||
</aside>
|
||
|
||
<!-- Internal-build tag — pinned to the app's bottom-right corner, above
|
||
whatever pane happens to be there. Purely informational: pointer
|
||
events pass through so it never blocks clicks. -->
|
||
<InternalBuildBanner class="internal-build-fab" />
|
||
|
||
<!-- Model Picker overlay -->
|
||
<ModelPicker
|
||
v-if="showModelPicker"
|
||
:models="client.models.value"
|
||
:current="client.status.value.modelId"
|
||
:starred-ids="client.starredModelIds.value"
|
||
:loading="modelsLoading"
|
||
:unavailable="modelsUnavailable"
|
||
@select="handleSelectModel($event)"
|
||
@toggle-star="client.toggleStarModel($event)"
|
||
@close="showModelPicker = false"
|
||
/>
|
||
|
||
<!-- Settings page (modal) -->
|
||
<SettingsDialog
|
||
v-if="showSettings"
|
||
:color-scheme="client.colorScheme.value"
|
||
:accent="client.accent.value"
|
||
:ui-font-size="client.uiFontSize.value"
|
||
:auth-ready="client.authReady.value"
|
||
:account-model="client.defaultModel.value"
|
||
:notify="client.notifyOnComplete.value"
|
||
:notify-question="client.notifyOnQuestion.value"
|
||
:notify-approval="client.notifyOnApproval.value"
|
||
:notify-permission="client.notifyPermission.value"
|
||
:sound="client.soundOnComplete.value"
|
||
:conversation-toc="client.conversationToc.value"
|
||
:config="client.config.value"
|
||
:models="client.models.value"
|
||
:config-saving="configSaving"
|
||
:server-version="client.serverVersion.value"
|
||
:backend="client.backend.value"
|
||
@set-color-scheme="client.setColorScheme($event)"
|
||
@set-accent="client.setAccent($event)"
|
||
@set-ui-font-size="client.setUiFontSize($event)"
|
||
@set-notify="client.setNotifyOnComplete($event)"
|
||
@set-notify-question="client.setNotifyOnQuestion($event)"
|
||
@set-notify-approval="client.setNotifyOnApproval($event)"
|
||
@set-sound="client.setSoundOnComplete($event)"
|
||
@set-conversation-toc="client.setConversationToc($event)"
|
||
@update-config="handleUpdateConfig($event)"
|
||
@login="() => { showSettings = false; openLogin(); }"
|
||
@logout="client.logout"
|
||
@open-onboarding="() => { showSettings = false; openOnboarding(); }"
|
||
@open-providers="() => { showSettings = false; openProviders(); }"
|
||
@close="showSettings = 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"
|
||
/>
|
||
|
||
<!-- 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"
|
||
:swarm-mode="client.swarmMode.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"
|
||
:default-path="client.visibleWorkspace.value?.root ?? client.status.value.cwd"
|
||
:error="addWorkspaceError"
|
||
@add="handleAddWorkspace($event)"
|
||
@close="handleCloseAddWorkspace"
|
||
/>
|
||
|
||
<!-- Global connecting splash on first load (until the daemon round-trips) -->
|
||
<Transition name="gload-fade">
|
||
<GlobalLoading v-if="!client.initialized.value" :issue="client.connectIssue.value" />
|
||
</Transition>
|
||
|
||
<!-- First-run onboarding overlay (language + welcome greeting). Held back
|
||
until the first load settled so it can't cover the connecting splash
|
||
(it teleports to <body> and would float above the retry error). -->
|
||
<Onboarding
|
||
v-if="client.initialized.value && showOnboarding && !showAuthGate"
|
||
@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" />
|
||
|
||
<!-- Global modal-confirmation host (driven by useConfirmDialog) -->
|
||
<ConfirmDialogHost />
|
||
|
||
<!-- 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="handleCreateSession"
|
||
@create-in-workspace="handleCreateSessionInWorkspace($event)"
|
||
@add-workspace="showAddWorkspace = true"
|
||
@rename="(id, title) => client.renameSession(id, title)"
|
||
@archive="(id) => client.archiveSession(id)"
|
||
@delete-workspace="(id) => client.deleteWorkspace(id)"
|
||
@load-more="(id) => void client.loadMoreSessions(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"
|
||
:models="client.models.value"
|
||
:plan-mode="client.planMode.value"
|
||
:swarm-mode="client.swarmMode.value"
|
||
:color-scheme="client.colorScheme.value"
|
||
:ui-font-size="client.uiFontSize.value"
|
||
:auth-ready="client.authReady.value"
|
||
:conversation-toc="client.conversationToc.value"
|
||
:server-version="client.serverVersion.value"
|
||
@pick-model="openModelPicker()"
|
||
@set-thinking="client.setThinking($event)"
|
||
@toggle-plan="client.togglePlanMode()"
|
||
@toggle-swarm="client.toggleSwarmMode()"
|
||
@set-permission="client.setPermission($event)"
|
||
@set-color-scheme="client.setColorScheme($event)"
|
||
@set-ui-font-size="client.setUiFontSize($event)"
|
||
@set-conversation-toc="client.setConversationToc($event)"
|
||
@login="() => { showMobileSettings = false; openLogin(); }"
|
||
@logout="client.logout"
|
||
/>
|
||
</div>
|
||
<!-- Login Dialog overlay. It is outside `.app` so `/login` can open it too. -->
|
||
<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"
|
||
/>
|
||
</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-shell {
|
||
height: 100vh;
|
||
height: 100dvh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
box-sizing: border-box;
|
||
}
|
||
.auth-page {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 32px;
|
||
background: var(--bg);
|
||
color: var(--color-text);
|
||
box-sizing: border-box;
|
||
}
|
||
.auth-page-inner {
|
||
width: min(420px, 100%);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 18px;
|
||
}
|
||
.auth-page-logo {
|
||
width: 64px;
|
||
height: 44px;
|
||
flex: none;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
transition: transform 0.18s ease;
|
||
}
|
||
.auth-page-logo:hover {
|
||
transform: scale(1.06);
|
||
}
|
||
.auth-page-copy {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.auth-page-copy h1 {
|
||
margin: 0;
|
||
font-family: var(--sans);
|
||
font-size: 30px;
|
||
line-height: 1.15;
|
||
font-weight: 500;
|
||
letter-spacing: 0;
|
||
color: var(--color-text);
|
||
}
|
||
.auth-page-copy p {
|
||
margin: 0;
|
||
font-family: var(--sans);
|
||
font-size: var(--ui-font-size-lg);
|
||
line-height: 1.55;
|
||
color: var(--dim);
|
||
}
|
||
.app {
|
||
--preview-w: 460px;
|
||
flex: 1;
|
||
min-height: 0;
|
||
position: relative;
|
||
display: grid;
|
||
/* sidebar | 0-width handle | conversation | 0-width handle | right panel.
|
||
The 4px ResizeHandles overflow their zero-width tracks via negative margins
|
||
so the whole strip is grabbable without consuming layout space. */
|
||
/* Both side tracks are PERMANENT (auto = follows the aside's width, 0 when
|
||
closed/collapsed) — opening or collapsing animates the aside's width, so
|
||
the conversation column is squeezed over smoothly instead of snapping to a
|
||
new template. Every column is pinned explicitly (grid-column 1–5) so a
|
||
display:none handle can't shift auto-placement. */
|
||
grid-template-columns: auto 0 minmax(0, 1fr) 0 auto;
|
||
background: var(--bg);
|
||
color: var(--color-text);
|
||
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;
|
||
}
|
||
|
||
/* Pin every desktop grid child to its track so auto-placement can never
|
||
reshuffle columns when a handle is display:none (v-show/v-if). */
|
||
.app > .side { grid-column: 1; }
|
||
.side-handle { grid-column: 2; }
|
||
.app:not(.mobile) > .con { grid-column: 3; }
|
||
.preview-handle { grid-column: 4; }
|
||
|
||
/* Sidebar toggle — floating button pinned to the top-left corner. On macOS
|
||
desktop it is resident (rendered in both states beside the traffic lights);
|
||
on Windows/web it only appears while the sidebar is collapsed (the collapse
|
||
button lives inside the sidebar header). While collapsed the conversation
|
||
header pads left so its content clears the button (global block below). */
|
||
.sidebar-toggle-btn {
|
||
position: absolute;
|
||
/* Vertically centered in the 48px conversation header. */
|
||
top: 11px;
|
||
left: 16px;
|
||
z-index: var(--z-sticky);
|
||
/* Fade in on appearance (Windows/web: only rendered while collapsed, so
|
||
this plays as the sidebar finishes sliding away). macOS disables it. */
|
||
animation: sidebar-toggle-btn-in 0.18s var(--ease-out) 0.12s backwards;
|
||
/* Floats over the macOS-desktop window-drag header; keep it clickable. */
|
||
-webkit-app-region: no-drag;
|
||
}
|
||
/* macOS desktop (hidden title bar): resident beside the floating traffic
|
||
lights (green light's right edge ≈ 68px; 72 keeps a gap that matches the
|
||
lights' own 8px rhythm); no entrance animation since it never appears. */
|
||
.app.macos-desktop .sidebar-toggle-btn {
|
||
left: 72px;
|
||
animation: none;
|
||
}
|
||
@keyframes sidebar-toggle-btn-in {
|
||
from { opacity: 0; }
|
||
}
|
||
|
||
/* Internal-build tag pinned to the app's bottom-right corner (desktop app
|
||
only — the component renders nothing elsewhere). Informational: never
|
||
intercepts pointer input. */
|
||
.internal-build-fab {
|
||
position: absolute;
|
||
right: var(--space-3);
|
||
bottom: var(--space-3);
|
||
z-index: var(--z-sticky);
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* 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: var(--z-sticky);
|
||
width: auto;
|
||
transition: none;
|
||
border-top: 2px solid var(--color-text);
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.auth-page {
|
||
align-items: flex-start;
|
||
padding:
|
||
max(48px, env(safe-area-inset-top))
|
||
max(20px, env(safe-area-inset-right))
|
||
max(24px, env(safe-area-inset-bottom))
|
||
max(20px, env(safe-area-inset-left));
|
||
}
|
||
.auth-page-copy h1 {
|
||
font-size: 26px;
|
||
}
|
||
.auth-page-btn {
|
||
width: 100%;
|
||
}
|
||
}
|
||
</style>
|
||
|
||
<style>
|
||
:root {
|
||
/* Right-side panel headers (ThinkingPanel / FilePreview / DiffView / SideChatPanel)
|
||
share the same 48px height as the conversation header so the hairline reads as
|
||
one continuous line across the layout. */
|
||
--panel-head-h: 48px;
|
||
}
|
||
|
||
/* Sidebar collapsed (desktop): the conversation header pads left so its
|
||
content clears the floating sidebar toggle (.sidebar-toggle-btn) — and the
|
||
macOS traffic lights on desktop builds. Animated in step with the sidebar
|
||
width transition. Cross-component rule (ChatHeader renders the header), so
|
||
it lives in this global block. */
|
||
.app:not(.mobile) .chat-header {
|
||
transition: padding-left 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
.app.sidebar-collapsed .chat-header {
|
||
padding-left: 52px;
|
||
}
|
||
.app.sidebar-collapsed.macos-desktop .chat-header {
|
||
padding-left: 108px;
|
||
}
|
||
</style>
|