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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 117 additions & 2 deletions apps/kimi-web/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import NewSessionDialog from './components/dialogs/NewSessionDialog.vue';
import SettingsDialog from './components/settings/SettingsDialog.vue';
import SessionsDialog from './components/dialogs/SessionsDialog.vue';
import AddWorkspaceDialog from './components/dialogs/AddWorkspaceDialog.vue';
import WorktreeBoard from './components/WorktreeBoard.vue';
import WorktreeCompleteDialog from './components/dialogs/WorktreeCompleteDialog.vue';
import NewWorktreeDialog from './components/dialogs/NewWorktreeDialog.vue';
import StatusPanel from './components/chat/StatusPanel.vue';
import WarningToasts from './components/WarningToasts.vue';
import MobileTopBar from './components/mobile/MobileTopBar.vue';
Expand All @@ -34,6 +37,7 @@ import { useFilePreview, type DetailTarget } from './composables/useFilePreview'
import { useDetailPanel } from './composables/useDetailPanel';
import { useIsMobile } from './composables/useIsMobile';
import type { AppConfig, ThinkingLevel } from './api/types';
import type { BoardSection, CompleteWorktreeTarget, WorkspaceView } from './types';

const client = useKimiWebClient();
provide('resolveImage', client.resolveImageUrl);
Expand Down Expand Up @@ -214,6 +218,19 @@ const showSessions = ref(false);
const showAddWorkspace = ref(false);
const showStatusPanel = ref(false);
const showSettings = ref(false);
const mainView = ref<'chat' | 'board'>('chat');
const boardLoading = ref(false);
// Global board: one section per git workspace, each carrying its worktrees and
// sessions. Recomputed reactively as worktrees load or change.
const boardSections = computed<BoardSection[]>(() =>
client.workspacesView.value
.filter((ws) => ws.isGitRepo === true)
.map((ws) => ({
workspace: ws,
worktrees: client.worktreesByWorkspace.value[ws.id] ?? [],
sessions: client.workspaceGroups.value.find((g) => g.workspace.id === ws.id)?.sessions ?? [],
})),
);

type SubmitPayload = {
text: string;
Expand All @@ -235,6 +252,7 @@ const anyOverlayOpen = computed<boolean>(() =>
showStatusPanel.value ||
showSettings.value ||
showOnboarding.value ||
showNewWorktree.value ||
showMobileSwitcher.value ||
showMobileSettings.value,
);
Expand Down Expand Up @@ -500,6 +518,63 @@ function handleCreateSessionInWorkspace(workspaceId: string): void {
function openPr(url: string): void {
if (url) window.open(url, '_blank', 'noopener');
}

// Sidebar worktree button (per-workspace) also opens the global board.
function handleManageWorktrees(_workspace: WorkspaceView): void {
void openGlobalBoard();
}

// Open the global worktree board: load worktrees for every git workspace, then
// show the board. Per-worktree loads are independent and best-effort.
async function openGlobalBoard(): Promise<void> {
mainView.value = 'board';
boardLoading.value = true;
try {
const gitWs = client.workspacesView.value.filter((w) => w.isGitRepo === true);
await Promise.allSettled(gitWs.map((w) => client.loadWorktrees(w.id)));
} finally {
boardLoading.value = false;
}
}

// Board "+ session" on a column: open a draft session scoped to that worktree,
// then return to the chat so the user can type the first message.
function handleOpenWorktreeFromBoard(workspaceId: string, path: string): void {
client.openWorkspaceDraft(workspaceId, path);
mainView.value = 'chat';
}

// Sidebar "+ session" on a worktree group: open a draft session scoped to that
// worktree checkout. The chat is already showing, so no view switch is needed.
function handleNewSessionInWorktree(workspaceId: string, path: string): void {
client.openWorkspaceDraft(workspaceId, path);
}

// Open a worktree folder in an external application (Cursor, VS Code, Finder, etc.).
function handleOpenWorktreeInApp(workspaceId: string, path: string, appId: string): void {
void client.openWorktreeInApp(workspaceId, appId, path);
}

// "Complete" a worktree — opens a confirmation dialog; the dialog itself calls
// removeWorktree and reports errors, then closes on success.
const completeTarget = ref<CompleteWorktreeTarget | null>(null);

function handleCompleteWorktree(target: CompleteWorktreeTarget): void {
completeTarget.value = target;
}

// Quick-create a worktree and jump straight into a draft session in it.
const showNewWorktree = ref(false);

function handleOpenNewWorktree(): void {
showNewWorktree.value = true;
}

function handleNewWorktreeCreated(workspaceId: string, path: string): void {
showNewWorktree.value = false;
mainView.value = 'chat';
client.openWorkspaceDraft(workspaceId, path);
}
</script>

<template>
Expand Down Expand Up @@ -546,11 +621,12 @@ function openPr(url: string): void {
:active-workspace="client.visibleWorkspace.value"
:active-workspace-id="client.activeWorkspaceId.value"
:sessions="client.sessionsForView.value"
:groups="client.workspaceGroups.value"
:groups="client.worktreeGroups.value"
:active-id="client.activeSessionId.value"
:attention-by-session="client.attentionBySession.value"
:pending-by-session="client.pendingBySession.value"
:unread-by-session="client.unreadBySession.value"
:available-open-in-apps="client.availableOpenInApps.value"
@select="client.selectSession($event)"
@create="handleCreateSession"
@create-in-workspace="handleCreateSessionInWorkspace($event)"
Expand All @@ -562,6 +638,13 @@ function openPr(url: string): void {
@rename-workspace="(id, name) => client.renameWorkspace(id, name)"
@delete-workspace="(id) => client.deleteWorkspace(id)"
@reorder-workspaces="client.reorderWorkspaces($event)"
@manage-worktrees="handleManageWorktrees"
@open-pr="openPr"
@open-board="openGlobalBoard"
@open-new-worktree="handleOpenNewWorktree"
@open-worktree="(wsId, path) => handleNewSessionInWorktree(wsId, path)"
@complete-worktree="handleCompleteWorktree"
@open-in-app="(wsId, path, appId) => handleOpenWorktreeInApp(wsId, path, appId)"
@select-workspaces="handleSelectWorkspaces"
@open-settings="showSettings = true"
@collapse="toggleSidebarCollapse"
Expand Down Expand Up @@ -604,8 +687,25 @@ function openPr(url: string): void {
@open-settings="showMobileSettings = true"
/>

<WorktreeBoard
v-if="mainView === 'board'"
:sections="boardSections"
:active-session-id="client.activeSessionId.value"
:pending-by-session="client.pendingBySession.value"
:unread-by-session="client.unreadBySession.value"
:loading="boardLoading"
:available-open-in-apps="client.availableOpenInApps.value"
@back="mainView = 'chat'"
@select-session="client.selectSession($event); mainView = 'chat'"
@open-worktree="(wsId, path) => handleOpenWorktreeFromBoard(wsId, path)"
@complete="handleCompleteWorktree"
@open-new-worktree="handleOpenNewWorktree"
@open-pr="openPr"
@open-in-app="(wsId, path, appId) => handleOpenWorktreeInApp(wsId, path, appId)"
/>

<ConversationPane
v-if="!hasMultiSelect"
v-else-if="!hasMultiSelect"
ref="conversationPaneRef"
:mobile="isMobile"
:modern="client.theme.value === 'modern' || client.theme.value === 'kimi'"
Expand Down Expand Up @@ -862,6 +962,21 @@ function openPr(url: string): void {
@close="handleCloseAddWorkspace"
/>

<WorktreeCompleteDialog
:target="completeTarget"
:remove-worktree="client.removeWorktree"
@cancel="completeTarget = null"
/>

<NewWorktreeDialog
v-if="showNewWorktree"
:workspaces="client.workspacesView.value"
:default-workspace-id="client.activeWorkspaceId.value"
:create-worktree="client.createWorktree"
@created="(wsId, path) => handleNewWorktreeCreated(wsId, path)"
@cancel="showNewWorktree = false"
/>

<!-- Global connecting splash on first load (until the daemon round-trips) -->
<Transition name="gload-fade">
<GlobalLoading v-if="!client.initialized.value" />
Expand Down
54 changes: 54 additions & 0 deletions apps/kimi-web/src/api/daemon/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
AppTaskStatus,
AppTerminal,
AppWorkspace,
AppWorktree,
ApprovalResponse,
FsBrowseResult,
FsEntry,
Expand Down Expand Up @@ -50,6 +51,7 @@ import {
toWireQuestionResponse,
toWireSessionStatus,
toAppWorkspace,
toAppWorktree,
wireEventSeq,
wireEventSessionId,
} from './mappers';
Expand Down Expand Up @@ -79,6 +81,7 @@ import type {
WireSessionRuntimeStatus,
WireSessionSnapshot,
WireWorkspace,
WireWorktree,
WireLogoutResult,
} from './wire';
import { DaemonEventSocket } from './ws';
Expand Down Expand Up @@ -985,6 +988,57 @@ export class DaemonKimiWebApi implements KimiWebApi {
await this.http.delete(`/workspaces/${encodeURIComponent(id)}`);
}

// -------------------------------------------------------------------------
// Worktrees — git worktrees of a workspace repository
// -------------------------------------------------------------------------

async listWorktrees(workspaceId: string): Promise<AppWorktree[]> {
const data = await this.http.get<{ worktrees: WireWorktree[] }>(
`/workspaces/${encodeURIComponent(workspaceId)}/worktrees`,
);
return (data.worktrees ?? []).map(toAppWorktree);
}

async createWorktree(
workspaceId: string,
input?: { branch?: string; baseRef?: string; path?: string },
): Promise<AppWorktree> {
const body: Record<string, unknown> = {};
if (input?.branch !== undefined) body['branch'] = input.branch;
if (input?.baseRef !== undefined) body['base_ref'] = input.baseRef;
if (input?.path !== undefined) body['path'] = input.path;
const data = await this.http.post<WireWorktree>(
`/workspaces/${encodeURIComponent(workspaceId)}/worktrees`,
body,
);
return toAppWorktree(data);
}

async removeWorktree(
workspaceId: string,
input: { path: string; force?: boolean; deleteBranch?: boolean },
): Promise<{ removed: true }> {
const body: Record<string, unknown> = { path: input.path };
if (input.force !== undefined) body['force'] = input.force;
if (input.deleteBranch !== undefined) body['delete_branch'] = input.deleteBranch;
await this.http.post<{ removed: true }>(
`/workspaces/${encodeURIComponent(workspaceId)}/worktrees/remove`,
body,
);
return { removed: true };
}

async openWorktreeInApp(
workspaceId: string,
appId: string,
path: string,
): Promise<void> {
await this.http.post<{ opened: true }>(
`/workspaces/${encodeURIComponent(workspaceId)}/worktrees/open-in`,
{ app_id: appId, path },
);
}

/**
* Browse directories under `path` (defaults to $HOME on the daemon).
* PRESUMED — GET /api/v1/fs:browse?path=. On error returns an empty path so
Expand Down
5 changes: 3 additions & 2 deletions apps/kimi-web/src/api/daemon/eventReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -589,11 +589,12 @@ export function reduceAppEvent(
break;
}

// Workspace lifecycle events are handled in the composable (rawState), not
// here — listed explicitly to keep the switch exhaustive.
// Workspace + worktree lifecycle events are handled in the composable
// (rawState), not here — listed explicitly to keep the switch exhaustive.
case 'workspaceCreated':
case 'workspaceUpdated':
case 'workspaceDeleted':
case 'worktreeChanged':
break;

default: {
Expand Down
26 changes: 26 additions & 0 deletions apps/kimi-web/src/api/daemon/mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
AppTask,
AppTaskStatus,
AppWorkspace,
AppWorktree,
ApprovalResponse,
ImageSource,
PromptSubmission,
Expand Down Expand Up @@ -49,6 +50,7 @@ import type {
WireSessionStatus,
WireSessionUsage,
WireWorkspace,
WireWorktree,
WireEvent,
WireConfig,
} from './wire';
Expand Down Expand Up @@ -125,6 +127,22 @@ export function toAppWorkspace(wire: WireWorkspace): AppWorkspace {
};
}

export function toAppWorktree(wire: WireWorktree): AppWorktree {
return {
path: wire.path,
branch: wire.branch,
head: wire.head,
isMain: wire.is_main,
locked: wire.locked,
prunable: wire.prunable,
dirty: wire.dirty,
ahead: wire.ahead,
behind: wire.behind,
sessionId: wire.session_id,
pullRequest: wire.pull_request ?? null,
};
}

// ---------------------------------------------------------------------------
// Message mappers
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -508,6 +526,14 @@ export function toAppEvent(wire: WireEvent): AppEvent {
root: w.payload.root,
};

case 'event.worktree.changed':
return {
type: 'worktreeChanged',
workspaceId: w.payload.workspace_id,
path: w.payload.path,
change: w.payload.change,
};

case 'event.session.status_changed':
return {
type: 'sessionStatusChanged',
Expand Down
15 changes: 15 additions & 0 deletions apps/kimi-web/src/api/daemon/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,21 @@ export interface WireWorkspace {
session_count: number;
}

/** A git worktree of a workspace repository (snake_case wire DTO). */
export interface WireWorktree {
path: string;
branch: string;
head: string;
is_main: boolean;
locked: boolean;
prunable: boolean;
dirty: boolean;
ahead: number;
behind: number;
session_id: string | null;
pull_request: { number: number; state: string; url: string } | null;
}

export interface WireFsBrowseEntry {
name: string;
path: string;
Expand Down
Loading