@agimon-ai/doompi-git 0.0.1-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/dist/_virtual/_rolldown/runtime.cjs +1 -0
- package/dist/adapters/channel/worktreeChannel.cjs +2 -0
- package/dist/adapters/channel/worktreeChannel.cjs.map +1 -0
- package/dist/adapters/channel/worktreeChannel.d.cts +40 -0
- package/dist/adapters/channel/worktreeChannel.d.cts.map +1 -0
- package/dist/adapters/channel/worktreeChannel.d.mts +40 -0
- package/dist/adapters/channel/worktreeChannel.d.mts.map +1 -0
- package/dist/adapters/channel/worktreeChannel.mjs +2 -0
- package/dist/adapters/channel/worktreeChannel.mjs.map +1 -0
- package/dist/adapters/filesystem/atomicJson.cjs +2 -0
- package/dist/adapters/filesystem/atomicJson.cjs.map +1 -0
- package/dist/adapters/filesystem/atomicJson.mjs +2 -0
- package/dist/adapters/filesystem/atomicJson.mjs.map +1 -0
- package/dist/adapters/filesystem/paths.cjs +2 -0
- package/dist/adapters/filesystem/paths.cjs.map +1 -0
- package/dist/adapters/filesystem/paths.mjs +2 -0
- package/dist/adapters/filesystem/paths.mjs.map +1 -0
- package/dist/adapters/hub/hubClient.cjs +2 -0
- package/dist/adapters/hub/hubClient.cjs.map +1 -0
- package/dist/adapters/hub/hubClient.d.cts +1 -0
- package/dist/adapters/hub/hubClient.d.mts +1 -0
- package/dist/adapters/hub/hubClient.mjs +2 -0
- package/dist/adapters/hub/hubClient.mjs.map +1 -0
- package/dist/adapters/pi/extension.cjs +2 -0
- package/dist/adapters/pi/extension.cjs.map +1 -0
- package/dist/adapters/pi/extension.d.cts +9 -0
- package/dist/adapters/pi/extension.d.cts.map +1 -0
- package/dist/adapters/pi/extension.d.mts +9 -0
- package/dist/adapters/pi/extension.d.mts.map +1 -0
- package/dist/adapters/pi/extension.mjs +2 -0
- package/dist/adapters/pi/extension.mjs.map +1 -0
- package/dist/adapters/pi/extensions/runWorktreeTool.cjs +15 -0
- package/dist/adapters/pi/extensions/runWorktreeTool.cjs.map +1 -0
- package/dist/adapters/pi/extensions/runWorktreeTool.mjs +15 -0
- package/dist/adapters/pi/extensions/runWorktreeTool.mjs.map +1 -0
- package/dist/adapters/web/worktreesChannel.cjs +2 -0
- package/dist/adapters/web/worktreesChannel.cjs.map +1 -0
- package/dist/adapters/web/worktreesChannel.d.cts +12 -0
- package/dist/adapters/web/worktreesChannel.d.cts.map +1 -0
- package/dist/adapters/web/worktreesChannel.d.mts +12 -0
- package/dist/adapters/web/worktreesChannel.d.mts.map +1 -0
- package/dist/adapters/web/worktreesChannel.mjs +2 -0
- package/dist/adapters/web/worktreesChannel.mjs.map +1 -0
- package/dist/adapters/worktree/dependencyInstall.cjs +2 -0
- package/dist/adapters/worktree/dependencyInstall.cjs.map +1 -0
- package/dist/adapters/worktree/dependencyInstall.mjs +2 -0
- package/dist/adapters/worktree/dependencyInstall.mjs.map +1 -0
- package/dist/adapters/worktree/gitCli.cjs +4 -0
- package/dist/adapters/worktree/gitCli.cjs.map +1 -0
- package/dist/adapters/worktree/gitCli.mjs +4 -0
- package/dist/adapters/worktree/gitCli.mjs.map +1 -0
- package/dist/adapters/worktree/repositoryIdentity.cjs +2 -0
- package/dist/adapters/worktree/repositoryIdentity.cjs.map +1 -0
- package/dist/adapters/worktree/repositoryIdentity.mjs +2 -0
- package/dist/adapters/worktree/repositoryIdentity.mjs.map +1 -0
- package/dist/adapters/worktree/worktreeOperations.cjs +2 -0
- package/dist/adapters/worktree/worktreeOperations.cjs.map +1 -0
- package/dist/adapters/worktree/worktreeOperations.d.cts +32 -0
- package/dist/adapters/worktree/worktreeOperations.d.cts.map +1 -0
- package/dist/adapters/worktree/worktreeOperations.d.mts +32 -0
- package/dist/adapters/worktree/worktreeOperations.d.mts.map +1 -0
- package/dist/adapters/worktree/worktreeOperations.mjs +2 -0
- package/dist/adapters/worktree/worktreeOperations.mjs.map +1 -0
- package/dist/adapters/worktree/worktreeRegistry.cjs +2 -0
- package/dist/adapters/worktree/worktreeRegistry.cjs.map +1 -0
- package/dist/adapters/worktree/worktreeRegistry.mjs +2 -0
- package/dist/adapters/worktree/worktreeRegistry.mjs.map +1 -0
- package/dist/commands/doomGitCommand.cjs +2 -0
- package/dist/commands/doomGitCommand.cjs.map +1 -0
- package/dist/commands/doomGitCommand.mjs +2 -0
- package/dist/commands/doomGitCommand.mjs.map +1 -0
- package/dist/container/index.cjs +2 -0
- package/dist/container/index.cjs.map +1 -0
- package/dist/container/index.d.cts +6 -0
- package/dist/container/index.d.cts.map +1 -0
- package/dist/container/index.d.mts +6 -0
- package/dist/container/index.d.mts.map +1 -0
- package/dist/container/index.mjs +2 -0
- package/dist/container/index.mjs.map +1 -0
- package/dist/extensions/pi.cjs +1 -0
- package/dist/extensions/pi.d.cts +2 -0
- package/dist/extensions/pi.d.mts +2 -0
- package/dist/extensions/pi.mjs +1 -0
- package/dist/hubApi.cjs +2 -0
- package/dist/hubApi.cjs.map +1 -0
- package/dist/hubApi.d.cts +6 -0
- package/dist/hubApi.d.cts.map +1 -0
- package/dist/hubApi.d.mts +6 -0
- package/dist/hubApi.d.mts.map +1 -0
- package/dist/hubApi.mjs +2 -0
- package/dist/hubApi.mjs.map +1 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.cts +5 -0
- package/dist/index.d.mts +5 -0
- package/dist/index.mjs +1 -0
- package/dist/schemas/runWorktreeTool.cjs +2 -0
- package/dist/schemas/runWorktreeTool.cjs.map +1 -0
- package/dist/schemas/runWorktreeTool.mjs +2 -0
- package/dist/schemas/runWorktreeTool.mjs.map +1 -0
- package/dist/services/extensionService.cjs +2 -0
- package/dist/services/extensionService.cjs.map +1 -0
- package/dist/services/extensionService.d.cts +8 -0
- package/dist/services/extensionService.d.cts.map +1 -0
- package/dist/services/extensionService.d.mts +8 -0
- package/dist/services/extensionService.d.mts.map +1 -0
- package/dist/services/extensionService.mjs +2 -0
- package/dist/services/extensionService.mjs.map +1 -0
- package/dist/services/support/errors.cjs +2 -0
- package/dist/services/support/errors.cjs.map +1 -0
- package/dist/services/support/errors.mjs +2 -0
- package/dist/services/support/errors.mjs.map +1 -0
- package/dist/services/worktreeNaming.cjs +2 -0
- package/dist/services/worktreeNaming.cjs.map +1 -0
- package/dist/services/worktreeNaming.d.cts +27 -0
- package/dist/services/worktreeNaming.d.cts.map +1 -0
- package/dist/services/worktreeNaming.d.mts +27 -0
- package/dist/services/worktreeNaming.d.mts.map +1 -0
- package/dist/services/worktreeNaming.mjs +2 -0
- package/dist/services/worktreeNaming.mjs.map +1 -0
- package/dist/types/extension.d.cts +17 -0
- package/dist/types/extension.d.cts.map +1 -0
- package/dist/types/extension.d.mts +17 -0
- package/dist/types/extension.d.mts.map +1 -0
- package/dist/types/webWorktrees.cjs +2 -0
- package/dist/types/webWorktrees.cjs.map +1 -0
- package/dist/types/webWorktrees.mjs +2 -0
- package/dist/types/webWorktrees.mjs.map +1 -0
- package/dist/types/worktreeRegistry.d.cts +41 -0
- package/dist/types/worktreeRegistry.d.cts.map +1 -0
- package/dist/types/worktreeRegistry.d.mts +41 -0
- package/dist/types/worktreeRegistry.d.mts.map +1 -0
- package/dist/webHub.cjs +1 -0
- package/dist/webHub.d.cts +2 -0
- package/dist/webHub.d.mts +2 -0
- package/dist/webHub.mjs +1 -0
- package/llms.txt +11 -0
- package/package.json +128 -0
- package/src/exports/webClient.ts +1 -0
- package/src/prompts/doompi-use-git/SKILL.md +64 -0
- package/src/types/webWorktrees.ts +31 -0
- package/src/types/worktreeRegistry.ts +69 -0
- package/src/web/components/RunWorktreeToolMessage.tsx +37 -0
- package/src/web/components/WorktreesActivitySection.tsx +62 -0
- package/src/web/components/WorktreesPanel.tsx +109 -0
- package/src/web/components/toolRenderers.ts +7 -0
- package/src/web/index.ts +27 -0
- package/src/web/stores/worktreesActivityStore.ts +78 -0
- package/src/web/stores/worktreesStore.ts +114 -0
- package/src/web/tsconfig.json +22 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cockpit's view of this session's worktrees.
|
|
3
|
+
*
|
|
4
|
+
* DESIGN PATTERNS:
|
|
5
|
+
* - Declarations only. The channel frame type is a constant so the hub and the
|
|
6
|
+
* page cannot drift apart on a string.
|
|
7
|
+
* - A view is the subset of a record the dock needs, not the record. The
|
|
8
|
+
* registry may hold paths and provenance the page has no business rendering.
|
|
9
|
+
*
|
|
10
|
+
* AVOID:
|
|
11
|
+
* - Adding behaviour here. This file is imported by browser code, so it must
|
|
12
|
+
* stay free of node builtins.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The data channel carrying one session's worktrees to the page. */
|
|
16
|
+
export const GIT_WORKTREES_TYPE = 'git_worktrees';
|
|
17
|
+
|
|
18
|
+
/** One worktree, as the activity dock and its panel render it. */
|
|
19
|
+
export interface WorktreeView {
|
|
20
|
+
id: string;
|
|
21
|
+
branch: string;
|
|
22
|
+
path: string;
|
|
23
|
+
/** The session the hub started for this worktree, when it is still live. */
|
|
24
|
+
sessionId: string | null;
|
|
25
|
+
/** True once the directory the record names has gone missing. */
|
|
26
|
+
orphaned: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface GitWorktreesPayload {
|
|
30
|
+
worktrees: WorktreeView[];
|
|
31
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The worktree registry: what this package knows about the worktrees it made.
|
|
3
|
+
*
|
|
4
|
+
* It deliberately does not mirror `git worktree list`. Git knows which
|
|
5
|
+
* worktrees exist; only this registry knows which of them this package created,
|
|
6
|
+
* which session runs in each, and which process to signal. Reconciling the two
|
|
7
|
+
* is how orphans are found, so the two views are kept separate on purpose.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A worktree's lifecycle as this package sees it.
|
|
12
|
+
*
|
|
13
|
+
* `orphaned` is a finding, not a cleanup: the session process is gone or the
|
|
14
|
+
* directory has vanished, but the worktree may still hold uncommitted work, so
|
|
15
|
+
* the record is kept until someone decides what to do with it.
|
|
16
|
+
*/
|
|
17
|
+
export type WorktreeStatus = 'spawning' | 'running' | 'closing' | 'orphaned';
|
|
18
|
+
|
|
19
|
+
export const WORKTREE_RECORD_VERSION = 1;
|
|
20
|
+
|
|
21
|
+
export interface WorktreeRecord {
|
|
22
|
+
version: typeof WORKTREE_RECORD_VERSION;
|
|
23
|
+
/** This registry's own id, distinct from the session id, and stable across restarts. */
|
|
24
|
+
id: string;
|
|
25
|
+
/** The branch checked out in the worktree. */
|
|
26
|
+
branch: string;
|
|
27
|
+
/** The ref the branch was created from, kept so a merge can name its base. */
|
|
28
|
+
baseRef: string;
|
|
29
|
+
/** Absolute path to the worktree directory. */
|
|
30
|
+
path: string;
|
|
31
|
+
/** Absolute path to the repository the worktree belongs to. */
|
|
32
|
+
repositoryRoot: string;
|
|
33
|
+
/** The session spawned into the worktree. */
|
|
34
|
+
sessionId: string;
|
|
35
|
+
/** The session that asked for the worktree; the rail nests one under the other. */
|
|
36
|
+
parentSessionId: string;
|
|
37
|
+
status: WorktreeStatus;
|
|
38
|
+
/** ISO 8601. */
|
|
39
|
+
createdAt: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface WorktreeRegistryFile {
|
|
43
|
+
version: typeof WORKTREE_RECORD_VERSION;
|
|
44
|
+
entries: readonly WorktreeRecord[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Reads the registry back, treating anything unusable as an empty registry. */
|
|
48
|
+
export interface WorktreeRegistryStore {
|
|
49
|
+
list(): WorktreeRecord[];
|
|
50
|
+
/** Replaces the whole file. One writer per repository, so no merge is attempted. */
|
|
51
|
+
replace(entries: readonly WorktreeRecord[]): void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The git operations this package needs, named in its own vocabulary. */
|
|
55
|
+
export interface WorktreeGit {
|
|
56
|
+
addWorktree(input: { repositoryRoot: string; path: string; branch: string; baseRef: string }): Promise<void>;
|
|
57
|
+
removeWorktree(input: { repositoryRoot: string; path: string; force: boolean }): Promise<void>;
|
|
58
|
+
pruneWorktrees(repositoryRoot: string): Promise<void>;
|
|
59
|
+
/** Paths git currently lists for the repository, used to find orphans. */
|
|
60
|
+
listWorktreePaths(repositoryRoot: string): Promise<string[]>;
|
|
61
|
+
/** Files with uncommitted changes, empty when the tree is clean. */
|
|
62
|
+
dirtyFiles(path: string): Promise<string[]>;
|
|
63
|
+
/** The repository root for a directory, or undefined when it is not a repository. */
|
|
64
|
+
repositoryRoot(cwd: string): Promise<string | undefined>;
|
|
65
|
+
/** The branch currently checked out, used as the default base ref. */
|
|
66
|
+
currentBranch(cwd: string): Promise<string | undefined>;
|
|
67
|
+
/** Merges a worktree's branch into whatever the given root has checked out. */
|
|
68
|
+
mergeBranch(input: { repositoryRoot: string; branch: string; message?: string }): Promise<void>;
|
|
69
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ToolMessageRenderProps } from '@agimon-ai/doompi-web-contracts';
|
|
2
|
+
|
|
3
|
+
const ACTION_LABEL: Readonly<Record<string, string>> = {
|
|
4
|
+
spawn_worktree: 'Create worktree',
|
|
5
|
+
close_worktree: 'Close worktree',
|
|
6
|
+
list: 'List worktrees',
|
|
7
|
+
status: 'Worktree status',
|
|
8
|
+
merge: 'Merge worktree',
|
|
9
|
+
prune: 'Prune worktrees',
|
|
10
|
+
send: 'Message worktree',
|
|
11
|
+
messages: 'Read worktree messages',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The timeline item for `run_worktree`.
|
|
16
|
+
*
|
|
17
|
+
* The action and its subject go in the heading because that is what a reader
|
|
18
|
+
* scanning a transcript needs: "close_worktree a1b2c3d4" answers the question
|
|
19
|
+
* the raw JSON arguments make them decode.
|
|
20
|
+
*/
|
|
21
|
+
export function RunWorktreeToolMessage({ args, output, running, isError }: ToolMessageRenderProps) {
|
|
22
|
+
const action = typeof args.action === 'string' ? args.action : '';
|
|
23
|
+
const subject = typeof args.branch === 'string' ? args.branch : typeof args.id === 'string' ? args.id : '';
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="flex flex-col gap-0.5">
|
|
27
|
+
<span className="text-[11px] font-bold text-doom-hi">
|
|
28
|
+
{ACTION_LABEL[action] ?? 'Worktree'}
|
|
29
|
+
{subject === '' ? '' : ` ${subject}`}
|
|
30
|
+
{running ? ' …' : ''}
|
|
31
|
+
</span>
|
|
32
|
+
{output !== '' && (
|
|
33
|
+
<pre className={`whitespace-pre-wrap text-[10px] ${isError ? 'text-doom-red' : 'text-doom-dim'}`}>{output}</pre>
|
|
34
|
+
)}
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The '# git' group's body in the activity dock.
|
|
3
|
+
*
|
|
4
|
+
* DESIGN PATTERNS:
|
|
5
|
+
* - One line per worktree, the same rule the runners rail follows: enough to
|
|
6
|
+
* notice it, not enough to work with it. The group's own name opens the tab
|
|
7
|
+
* that has room.
|
|
8
|
+
* - Idle still shows the launcher. A repository with no worktrees is the state
|
|
9
|
+
* where someone most wants to make one.
|
|
10
|
+
*
|
|
11
|
+
* AVOID:
|
|
12
|
+
* - Doing work here. This section reports and launches; the panel and the tool
|
|
13
|
+
* own everything else.
|
|
14
|
+
*/
|
|
15
|
+
import { Button } from '@agimon-ai/doompi-web-components';
|
|
16
|
+
import type { WebPluginSlotProps } from '@agimon-ai/doompi-web-contracts';
|
|
17
|
+
import { useStore } from '@tanstack/react-store';
|
|
18
|
+
import type { WorktreeView } from '../../types/webWorktrees.ts';
|
|
19
|
+
import { worktreeActivity } from '../stores/worktreesActivityStore.ts';
|
|
20
|
+
import { worktreesTab } from './WorktreesPanel.tsx';
|
|
21
|
+
|
|
22
|
+
export function WorktreesActivitySection({ sessionId, openTransientTab }: WebPluginSlotProps) {
|
|
23
|
+
const session = useStore(worktreeActivity.store, (state) => worktreeActivity.select(state, sessionId));
|
|
24
|
+
|
|
25
|
+
if (session.worktrees.length === 0) {
|
|
26
|
+
return (
|
|
27
|
+
<div className="flex items-center gap-2 px-1">
|
|
28
|
+
<p data-testid="activity-summary-git" className="px-1 text-[10px] text-doom-faint">
|
|
29
|
+
idle
|
|
30
|
+
</p>
|
|
31
|
+
{sessionId === null ? null : (
|
|
32
|
+
<Button
|
|
33
|
+
variant="link"
|
|
34
|
+
size="xs"
|
|
35
|
+
data-testid="activity-git-create"
|
|
36
|
+
className="px-0"
|
|
37
|
+
onClick={() => openTransientTab(worktreesTab())}
|
|
38
|
+
>
|
|
39
|
+
create a worktree
|
|
40
|
+
</Button>
|
|
41
|
+
)}
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div data-testid="activity-git-worktrees" className="flex flex-col gap-0.5">
|
|
48
|
+
{session.worktrees.map((worktree: WorktreeView) => (
|
|
49
|
+
<button
|
|
50
|
+
key={worktree.id}
|
|
51
|
+
type="button"
|
|
52
|
+
data-testid={`activity-git-worktree-${worktree.id}`}
|
|
53
|
+
className="flex items-center gap-2 px-1 text-left text-[11px] text-doom-dim hover:text-doom-bright"
|
|
54
|
+
onClick={() => openTransientTab(worktreesTab())}
|
|
55
|
+
>
|
|
56
|
+
<span className="truncate">{worktree.branch}</span>
|
|
57
|
+
{worktree.orphaned ? <span className="text-[10px] text-doom-faint">orphaned</span> : null}
|
|
58
|
+
</button>
|
|
59
|
+
))}
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The '# git' group's tab: this session's worktrees, and the form that makes one.
|
|
3
|
+
*
|
|
4
|
+
* DESIGN PATTERNS:
|
|
5
|
+
* - Lives in the activity dock, opened by the group's name. A worktree is work
|
|
6
|
+
* in progress belonging to the session that started it, so it sits beside
|
|
7
|
+
* agents, runners and workflows rather than in a settings screen.
|
|
8
|
+
* - Local form state is never reset by a data refresh. The hub republishes this
|
|
9
|
+
* list on a timer, and a half-typed branch name must survive that.
|
|
10
|
+
* - Acts by asking the agent, so a worktree made here and one made by the tool
|
|
11
|
+
* take exactly the same path.
|
|
12
|
+
*
|
|
13
|
+
* AVOID:
|
|
14
|
+
* - Deriving form state from props or store data. That is what wiped the
|
|
15
|
+
* fields on every poll.
|
|
16
|
+
*/
|
|
17
|
+
import { Button, Input } from '@agimon-ai/doompi-web-components';
|
|
18
|
+
import type { TransientTab, WebPluginSlotProps } from '@agimon-ai/doompi-web-contracts';
|
|
19
|
+
import { useStore } from '@tanstack/react-store';
|
|
20
|
+
import { useState } from 'react';
|
|
21
|
+
import type { WorktreeView } from '../../types/webWorktrees.ts';
|
|
22
|
+
import { requestWorktreeClose, requestWorktreeCreate, worktreeActivity } from '../stores/worktreesActivityStore.ts';
|
|
23
|
+
|
|
24
|
+
const WORKTREES_TAB_ID = 'git-worktrees';
|
|
25
|
+
|
|
26
|
+
export function worktreesTab(): TransientTab {
|
|
27
|
+
return { id: WORKTREES_TAB_ID, label: 'worktrees', panel: WorktreesPanel };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function WorktreesPanel({ sessionId, sendSessionFrame }: WebPluginSlotProps) {
|
|
31
|
+
const session = useStore(worktreeActivity.store, (state) => worktreeActivity.select(state, sessionId));
|
|
32
|
+
const [branch, setBranch] = useState('');
|
|
33
|
+
const [baseRef, setBaseRef] = useState('');
|
|
34
|
+
|
|
35
|
+
const canCreate = sessionId !== null && branch.trim() !== '';
|
|
36
|
+
|
|
37
|
+
const create = (): void => {
|
|
38
|
+
if (sessionId === null || !canCreate) return;
|
|
39
|
+
requestWorktreeCreate(sendSessionFrame, sessionId, branch, baseRef);
|
|
40
|
+
setBranch('');
|
|
41
|
+
setBaseRef('');
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className="flex flex-col gap-3 p-3" data-testid="git-worktrees-panel">
|
|
46
|
+
<div className="flex flex-col gap-1">
|
|
47
|
+
<div className="flex items-center gap-2">
|
|
48
|
+
<Input
|
|
49
|
+
data-testid="git-worktree-branch"
|
|
50
|
+
aria-label="Branch"
|
|
51
|
+
placeholder="wt/fix-auth"
|
|
52
|
+
value={branch}
|
|
53
|
+
onChange={(event) => setBranch(event.target.value)}
|
|
54
|
+
onKeyDown={(event) => {
|
|
55
|
+
if (event.key === 'Enter') create();
|
|
56
|
+
}}
|
|
57
|
+
/>
|
|
58
|
+
<Input
|
|
59
|
+
data-testid="git-worktree-base"
|
|
60
|
+
aria-label="Base ref"
|
|
61
|
+
placeholder="base (current)"
|
|
62
|
+
value={baseRef}
|
|
63
|
+
onChange={(event) => setBaseRef(event.target.value)}
|
|
64
|
+
className="w-40"
|
|
65
|
+
/>
|
|
66
|
+
<Button data-testid="git-worktree-create" size="xs" disabled={!canCreate} onClick={create}>
|
|
67
|
+
Create
|
|
68
|
+
</Button>
|
|
69
|
+
</div>
|
|
70
|
+
<p className="text-[10px] text-doom-faint">
|
|
71
|
+
Creates the worktree outside the repository, installs its dependencies, then starts a session nested under
|
|
72
|
+
this one. The install is what makes the wait; a warm store is under a minute.
|
|
73
|
+
</p>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
{session.worktrees.length === 0 ? (
|
|
77
|
+
<p className="text-[11px] text-doom-faint">No worktrees yet.</p>
|
|
78
|
+
) : (
|
|
79
|
+
<ul className="flex flex-col gap-1">
|
|
80
|
+
{session.worktrees.map((worktree: WorktreeView) => (
|
|
81
|
+
<li
|
|
82
|
+
key={worktree.id}
|
|
83
|
+
data-testid={`git-worktree-${worktree.id}`}
|
|
84
|
+
className="flex items-center justify-between gap-2 border border-doom-line px-2 py-1"
|
|
85
|
+
>
|
|
86
|
+
<div className="flex min-w-0 flex-col">
|
|
87
|
+
<span className="truncate text-[11px] text-doom-bright">{worktree.branch}</span>
|
|
88
|
+
<span className="truncate text-[10px] text-doom-faint">{worktree.path}</span>
|
|
89
|
+
</div>
|
|
90
|
+
<div className="flex items-center gap-2">
|
|
91
|
+
{worktree.orphaned ? <span className="text-[10px] text-doom-faint">orphaned</span> : null}
|
|
92
|
+
{sessionId === null ? null : (
|
|
93
|
+
<Button
|
|
94
|
+
variant="link"
|
|
95
|
+
size="xs"
|
|
96
|
+
data-testid={`git-worktree-close-${worktree.id}`}
|
|
97
|
+
onClick={() => requestWorktreeClose(sendSessionFrame, sessionId, worktree.id)}
|
|
98
|
+
>
|
|
99
|
+
close
|
|
100
|
+
</Button>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
</li>
|
|
104
|
+
))}
|
|
105
|
+
</ul>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ToolRendererContribution } from '@agimon-ai/doompi-web-contracts';
|
|
2
|
+
import { RunWorktreeToolMessage } from './RunWorktreeToolMessage.tsx';
|
|
3
|
+
|
|
4
|
+
/** The timeline items for this package's tools. */
|
|
5
|
+
export const gitToolRenderers: ToolRendererContribution[] = [
|
|
6
|
+
{ tools: ['run_worktree'], message: RunWorktreeToolMessage },
|
|
7
|
+
];
|
package/src/web/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { defineWebPlugin } from '@agimon-ai/doompi-web-contracts';
|
|
2
|
+
import { gitToolRenderers } from './components/toolRenderers.ts';
|
|
3
|
+
import { WorktreesActivitySection } from './components/WorktreesActivitySection.tsx';
|
|
4
|
+
import { worktreesTab } from './components/WorktreesPanel.tsx';
|
|
5
|
+
import { worktreeActivitySource, worktreesChannel } from './stores/worktreesActivityStore.ts';
|
|
6
|
+
|
|
7
|
+
/** The named export the generated plugin registry imports. */
|
|
8
|
+
export const webPlugin = defineWebPlugin({
|
|
9
|
+
id: 'git',
|
|
10
|
+
channels: [worktreesChannel],
|
|
11
|
+
activityGroups: [
|
|
12
|
+
{
|
|
13
|
+
name: 'git',
|
|
14
|
+
keys: 'g w',
|
|
15
|
+
// The source keeps the group present while idle, which is exactly when
|
|
16
|
+
// its launcher matters: a repository with no worktrees is where someone
|
|
17
|
+
// wants to make the first one.
|
|
18
|
+
activeSource: worktreeActivitySource,
|
|
19
|
+
// A standing worktree is not background work in progress; it is state.
|
|
20
|
+
marksBackgroundWork: false,
|
|
21
|
+
transientTab: worktreesTab,
|
|
22
|
+
order: 30,
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
activitySections: [{ id: 'git', component: WorktreesActivitySection }],
|
|
26
|
+
toolRenderers: gitToolRenderers,
|
|
27
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dock's view of this session's worktrees.
|
|
3
|
+
*
|
|
4
|
+
* DESIGN PATTERNS:
|
|
5
|
+
* - Session-scoped store fed by one hub channel. The hub is authoritative; the
|
|
6
|
+
* page never reads a registry and never guesses.
|
|
7
|
+
* - Actions are prompts, the same route the runner's launcher takes. The agent
|
|
8
|
+
* owns the tool, so the panel asks for the work rather than performing it,
|
|
9
|
+
* and a worktree made from the dock is indistinguishable from one the agent
|
|
10
|
+
* made on its own.
|
|
11
|
+
*
|
|
12
|
+
* AVOID:
|
|
13
|
+
* - Calling the hub package API from here. A dock slot gets no sealed request
|
|
14
|
+
* transport, and raw fetch breaks the remote cockpit.
|
|
15
|
+
*/
|
|
16
|
+
import { defineSessionStore, type SessionFrameSender } from '@agimon-ai/doompi-web-contracts';
|
|
17
|
+
import { GIT_WORKTREES_TYPE, type WorktreeView } from '../../types/webWorktrees.ts';
|
|
18
|
+
|
|
19
|
+
export interface WorktreesSession {
|
|
20
|
+
/** The worktrees the hub last reported for this session's repository. */
|
|
21
|
+
worktrees: WorktreeView[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const worktreeActivity = defineSessionStore<WorktreesSession>({ worktrees: [] });
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Keeps the group visible whenever this repository has worktrees.
|
|
28
|
+
*
|
|
29
|
+
* A worktree is standing state, not a burst of work: it stays until someone
|
|
30
|
+
* closes it, so its presence is what the dock reports.
|
|
31
|
+
*/
|
|
32
|
+
export const worktreeActivitySource = {
|
|
33
|
+
subscribe(listener: () => void) {
|
|
34
|
+
const subscription = worktreeActivity.store.subscribe(listener);
|
|
35
|
+
return () => subscription.unsubscribe();
|
|
36
|
+
},
|
|
37
|
+
isActive(sessionId: string | null) {
|
|
38
|
+
return worktreeActivity.select(worktreeActivity.store.state, sessionId).worktrees.length > 0;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
43
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The plugin's session data channel: 'git_worktrees' payloads into the store. */
|
|
47
|
+
export const worktreesChannel = worktreeActivity.channel<WorktreesSession>({
|
|
48
|
+
channel: GIT_WORKTREES_TYPE,
|
|
49
|
+
parse(input) {
|
|
50
|
+
if (!isRecord(input) || !Array.isArray(input.worktrees)) return null;
|
|
51
|
+
return { worktrees: input.worktrees.filter(isRecord) as unknown as WorktreeView[] };
|
|
52
|
+
},
|
|
53
|
+
reduce(_current, next) {
|
|
54
|
+
return next;
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** Asks the agent for a worktree, through the tool it already owns. */
|
|
59
|
+
export function requestWorktreeCreate(
|
|
60
|
+
send: SessionFrameSender,
|
|
61
|
+
sessionId: string,
|
|
62
|
+
branch: string,
|
|
63
|
+
baseRef: string,
|
|
64
|
+
): void {
|
|
65
|
+
const base = baseRef.trim() === '' ? '' : ` from ${baseRef.trim()}`;
|
|
66
|
+
send(sessionId, {
|
|
67
|
+
type: 'prompt',
|
|
68
|
+
message: `Create a git worktree on branch ${branch.trim()}${base} using the run_worktree tool.`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Asks the agent to close one worktree. Refusal on a dirty tree stays the tool's call. */
|
|
73
|
+
export function requestWorktreeClose(send: SessionFrameSender, sessionId: string, id: string): void {
|
|
74
|
+
send(sessionId, {
|
|
75
|
+
type: 'prompt',
|
|
76
|
+
message: `Close the git worktree ${id} using the run_worktree tool.`,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { Store } from '@tanstack/store';
|
|
2
|
+
import type { WorktreeRecord } from '../../types/worktreeRegistry.ts';
|
|
3
|
+
|
|
4
|
+
const API_BASE = '/api/plugin/git';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The host's transport, not the global fetch.
|
|
8
|
+
*
|
|
9
|
+
* A cockpit reached through the remote tunnel routes package API calls through
|
|
10
|
+
* a sealed transport; calling `fetch` directly works locally and then fails for
|
|
11
|
+
* exactly the users who are hardest to debug for.
|
|
12
|
+
*/
|
|
13
|
+
export type HostRequest = (input: string, init?: RequestInit) => Promise<Response>;
|
|
14
|
+
|
|
15
|
+
export interface WorktreesState {
|
|
16
|
+
/** Keyed by repository, because the panel follows the focused session. */
|
|
17
|
+
byRepository: Record<string, readonly WorktreeRecord[]>;
|
|
18
|
+
loading: boolean;
|
|
19
|
+
/** The last failure, shown in place of a silent empty list. */
|
|
20
|
+
error: string | undefined;
|
|
21
|
+
busy: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const worktreesStore = new Store<WorktreesState>({
|
|
25
|
+
byRepository: {},
|
|
26
|
+
loading: false,
|
|
27
|
+
error: undefined,
|
|
28
|
+
busy: false,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export function selectWorktrees(state: WorktreesState, repositoryId: string | null): readonly WorktreeRecord[] {
|
|
32
|
+
return repositoryId === null ? [] : (state.byRepository[repositoryId] ?? []);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function readError(response: Response): Promise<string> {
|
|
36
|
+
const body = (await response.json().catch(() => ({}))) as { error?: string };
|
|
37
|
+
return body.error ?? `Request failed (${String(response.status)}).`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Loads the worktrees for a repository, replacing whatever was shown. */
|
|
41
|
+
export async function loadWorktrees(request: HostRequest, repositoryId: string): Promise<void> {
|
|
42
|
+
worktreesStore.setState((state) => ({ ...state, loading: true, error: undefined }));
|
|
43
|
+
try {
|
|
44
|
+
const response = await request(`${API_BASE}/worktrees?repositoryId=${encodeURIComponent(repositoryId)}`);
|
|
45
|
+
if (!response.ok) throw new Error(await readError(response));
|
|
46
|
+
const body = (await response.json()) as { worktrees: WorktreeRecord[] };
|
|
47
|
+
worktreesStore.setState((state) => ({
|
|
48
|
+
...state,
|
|
49
|
+
loading: false,
|
|
50
|
+
byRepository: { ...state.byRepository, [repositoryId]: body.worktrees },
|
|
51
|
+
}));
|
|
52
|
+
} catch (error) {
|
|
53
|
+
worktreesStore.setState((state) => ({ ...state, loading: false, error: (error as Error).message }));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface CreateWorktreeInput {
|
|
58
|
+
repositoryId: string;
|
|
59
|
+
branch: string;
|
|
60
|
+
baseRef?: string;
|
|
61
|
+
name?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Creates a worktree and its session through the hub.
|
|
66
|
+
*
|
|
67
|
+
* The same route the `run_worktree` tool calls. One path for both so the panel
|
|
68
|
+
* and the agent cannot drift into two different ideas of what creating a
|
|
69
|
+
* worktree means.
|
|
70
|
+
*/
|
|
71
|
+
export async function createWorktree(request: HostRequest, input: CreateWorktreeInput): Promise<boolean> {
|
|
72
|
+
worktreesStore.setState((state) => ({ ...state, busy: true, error: undefined }));
|
|
73
|
+
try {
|
|
74
|
+
const response = await request(`${API_BASE}/worktrees?repositoryId=${encodeURIComponent(input.repositoryId)}`, {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { 'content-type': 'application/json' },
|
|
77
|
+
body: JSON.stringify({
|
|
78
|
+
branch: input.branch,
|
|
79
|
+
...(input.baseRef === undefined || input.baseRef === '' ? {} : { baseRef: input.baseRef }),
|
|
80
|
+
...(input.name === undefined || input.name === '' ? {} : { name: input.name }),
|
|
81
|
+
}),
|
|
82
|
+
});
|
|
83
|
+
if (!response.ok) throw new Error(await readError(response));
|
|
84
|
+
await loadWorktrees(request, input.repositoryId);
|
|
85
|
+
worktreesStore.setState((state) => ({ ...state, busy: false }));
|
|
86
|
+
return true;
|
|
87
|
+
} catch (error) {
|
|
88
|
+
worktreesStore.setState((state) => ({ ...state, busy: false, error: (error as Error).message }));
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Closes a worktree. `force` discards uncommitted work and is never implicit. */
|
|
94
|
+
export async function closeWorktree(
|
|
95
|
+
request: HostRequest,
|
|
96
|
+
repositoryId: string,
|
|
97
|
+
id: string,
|
|
98
|
+
force: boolean,
|
|
99
|
+
): Promise<boolean> {
|
|
100
|
+
worktreesStore.setState((state) => ({ ...state, busy: true, error: undefined }));
|
|
101
|
+
try {
|
|
102
|
+
const query = new URLSearchParams({ repositoryId, ...(force ? { force: 'true' } : {}) });
|
|
103
|
+
const response = await request(`${API_BASE}/worktrees/${encodeURIComponent(id)}?${query.toString()}`, {
|
|
104
|
+
method: 'DELETE',
|
|
105
|
+
});
|
|
106
|
+
if (!response.ok) throw new Error(await readError(response));
|
|
107
|
+
await loadWorktrees(request, repositoryId);
|
|
108
|
+
worktreesStore.setState((state) => ({ ...state, busy: false }));
|
|
109
|
+
return true;
|
|
110
|
+
} catch (error) {
|
|
111
|
+
worktreesStore.setState((state) => ({ ...state, busy: false, error: (error as Error).message }));
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "Bundler",
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noImplicitAny": true,
|
|
10
|
+
"noUnusedLocals": true,
|
|
11
|
+
"noUnusedParameters": true,
|
|
12
|
+
"noImplicitReturns": true,
|
|
13
|
+
"noFallthroughCasesInSwitch": true,
|
|
14
|
+
"noEmit": true,
|
|
15
|
+
"resolveJsonModule": true,
|
|
16
|
+
"esModuleInterop": true,
|
|
17
|
+
"skipLibCheck": true,
|
|
18
|
+
"allowImportingTsExtensions": true,
|
|
19
|
+
"forceConsistentCasingInFileNames": true
|
|
20
|
+
},
|
|
21
|
+
"include": [".", "../types/worktreeRegistry.ts", "../types/webWorktrees.ts", "../exports/webClient.ts"]
|
|
22
|
+
}
|