@fonlan/dsh-task-kanban 0.1.0 → 0.1.1
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/README.md +2 -1
- package/lib/client.js +241 -40
- package/lib/client.js.map +1 -1
- package/lib/index.js +193 -8
- package/lib/types/server/prompts.d.ts +7 -0
- package/lib/types/server/runner.d.ts +23 -0
- package/lib/types/server/skills.d.ts +71 -0
- package/lib/types/server/task-store.d.ts +1 -0
- package/lib/types/shared/card.d.ts +2 -0
- package/package.json +6 -1
- package/src/client/api.ts +43 -2
- package/src/client/board.css +80 -18
- package/src/client/board.tsx +145 -10
- package/src/client/kanban-state.ts +65 -23
- package/src/client/locales.ts +8 -0
- package/src/index.ts +1 -1
- package/src/server/prompts.ts +24 -0
- package/src/server/rpc.ts +2 -1
- package/src/server/runner.ts +114 -9
- package/src/server/skills.ts +105 -0
- package/src/server/task-store.ts +2 -0
- package/src/shared/card.ts +2 -0
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import type { KanbanCard } from '../shared/card.js';
|
|
2
2
|
/** Refinement session prompt: analyze the repo read-only and write back a plan. */
|
|
3
3
|
export declare function refinementPrompt(requirement: string, workspacePath: string): string;
|
|
4
|
+
/**
|
|
5
|
+
* Interactive refinement prompt for a card created with a skill (e.g.
|
|
6
|
+
* `/grill-me`). The refinement session is explicit — the user can reply —
|
|
7
|
+
* so instead of deciding autonomously, the agent follows the skill's
|
|
8
|
+
* interview and asks whatever it needs before writing the plan back.
|
|
9
|
+
*/
|
|
10
|
+
export declare function interactiveRefinementPrompt(requirement: string, workspacePath: string): string;
|
|
4
11
|
/** Phase implementation session prompt; identical on retries (the model reads git status/diff itself). */
|
|
5
12
|
export declare function phasePrompt(card: KanbanCard, phaseIndex: number, workdir: string): string;
|
|
6
13
|
/** Merge-conflict resolution session prompt: resolve conflicts only, never commit. */
|
|
@@ -36,6 +36,7 @@ export declare class KanbanRunner implements KanbanToolResolver {
|
|
|
36
36
|
requirement: string;
|
|
37
37
|
model: string;
|
|
38
38
|
provider?: string;
|
|
39
|
+
skill?: string;
|
|
39
40
|
}): Promise<KanbanCard>;
|
|
40
41
|
moveTask(cardId: string, toLane: Lane): Promise<{
|
|
41
42
|
ok: boolean;
|
|
@@ -59,6 +60,28 @@ export declare class KanbanRunner implements KanbanToolResolver {
|
|
|
59
60
|
private requestStop;
|
|
60
61
|
private fail;
|
|
61
62
|
private runRefinement;
|
|
63
|
+
/**
|
|
64
|
+
* Drive the refinement session to a terminal card state. Non-interactive
|
|
65
|
+
* cards follow the original contract: the agent finishes one turn and must
|
|
66
|
+
* have written the plan. Interactive cards (created with a refinement
|
|
67
|
+
* skill) may ask the user clarifying questions — the session is explicit
|
|
68
|
+
* and visible in the workspace chat, so the runner waits for each reply
|
|
69
|
+
* instead of failing, and only settles when the plan is written or the
|
|
70
|
+
* session errors out.
|
|
71
|
+
*/
|
|
72
|
+
private driveRefinement;
|
|
73
|
+
/**
|
|
74
|
+
* A promise resolving when the card stops being 'refining' (planned,
|
|
75
|
+
* failed, or gone), plus a disposer stopping the polling interval.
|
|
76
|
+
*/
|
|
77
|
+
private waitForCardSettlement;
|
|
78
|
+
/**
|
|
79
|
+
* A promise resolving when a NEW human message enters the given session,
|
|
80
|
+
* plus a disposer detaching the listener. Messages injected by plugins with
|
|
81
|
+
* non-`user` sources (such as our skill-invocation instructions) never
|
|
82
|
+
* resolve it — only real human replies drive the interactive refinement.
|
|
83
|
+
*/
|
|
84
|
+
private waitForUserReply;
|
|
62
85
|
private createAgent;
|
|
63
86
|
private keepHandle;
|
|
64
87
|
/**
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill wiring for requirement refinement.
|
|
3
|
+
*
|
|
4
|
+
* A task card may carry a skill name (e.g. `grill-me`) chosen in the
|
|
5
|
+
* create-task requirement input via a leading `/skill-name` gesture, matching
|
|
6
|
+
* the harness's user-invocation convention (the same whitespace-bounded token
|
|
7
|
+
* shape `tool-skill` scans for). The server strips the gesture from the stored
|
|
8
|
+
* requirement, records the skill on the card, and injects the canonical
|
|
9
|
+
* `renderSkillContent` block into the refinement session as an instructions-
|
|
10
|
+
* form user message with the harness's `skill-invocation` source, so the
|
|
11
|
+
* transcript UI renders it exactly like a user-invoked skill in chat.
|
|
12
|
+
*/
|
|
13
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
14
|
+
import { type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill';
|
|
15
|
+
/** A parsed leading skill gesture: the skill name plus the cleaned requirement. */
|
|
16
|
+
export interface SkillGestureParse {
|
|
17
|
+
/** The skill name from the leading `/name` token, if the input led with one. */
|
|
18
|
+
skill?: string;
|
|
19
|
+
/** The requirement text with the leading gesture removed. */
|
|
20
|
+
requirement: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Extract a leading `/skill-name` gesture from raw create-task input. Only a
|
|
24
|
+
* token at the very start selects a skill; tokens later in the text are
|
|
25
|
+
* ordinary requirement prose (the user might legitimately mention paths or
|
|
26
|
+
* fractions). The name is grammar-validated but not registry-checked here —
|
|
27
|
+
* unknown names surface as a refinement error with a clear message.
|
|
28
|
+
*/
|
|
29
|
+
export declare function parseSkillGesture(raw: string): SkillGestureParse;
|
|
30
|
+
/**
|
|
31
|
+
* The minimal view of the hosted skill registry the plugin needs: list for a
|
|
32
|
+
* summary lookup and get for a full body, both honoring the calling scope.
|
|
33
|
+
*/
|
|
34
|
+
export interface SkillRegistryFace {
|
|
35
|
+
list(options?: {
|
|
36
|
+
cwd?: string;
|
|
37
|
+
scope?: unknown;
|
|
38
|
+
}): Promise<SkillSummary[]>;
|
|
39
|
+
get(name: string, options?: {
|
|
40
|
+
cwd?: string;
|
|
41
|
+
scope?: unknown;
|
|
42
|
+
}): Promise<SkillDefinition | undefined>;
|
|
43
|
+
}
|
|
44
|
+
/** Resolve the host skill registry, or undefined when this profile lacks one. */
|
|
45
|
+
export declare function skillRegistry(ctx: Context): SkillRegistryFace | undefined;
|
|
46
|
+
/** Whether the provided skill name is a syntactically valid skill name. */
|
|
47
|
+
export declare function validSkillName(name: string): boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Build the instructions-form message carrying the rendered skill body, the
|
|
50
|
+
* same shape a user `/grill-me` invocation injects through `tool-skill`. The
|
|
51
|
+
* `skill-invocation` source is what the transcript UI decorates as a skill
|
|
52
|
+
* chip; the content is the canonical `<skill_content>` block.
|
|
53
|
+
*/
|
|
54
|
+
export declare function skillInvocationMessage(skill: Pick<SkillDefinition, 'name' | 'provider' | 'resourceBase' | 'content'>): {
|
|
55
|
+
content: {
|
|
56
|
+
type: "text";
|
|
57
|
+
text: string;
|
|
58
|
+
}[];
|
|
59
|
+
source: {
|
|
60
|
+
kind: "skill-invocation";
|
|
61
|
+
name: string;
|
|
62
|
+
form: "instructions";
|
|
63
|
+
};
|
|
64
|
+
} & Pick<import("@deepseek-ai/dsh-llm").UserMessage, "id" | "role">;
|
|
65
|
+
/**
|
|
66
|
+
* Load the skill body the card names, scoped to the refinement agent so
|
|
67
|
+
* preset-layer providers (the filesystem skill roots) are visible. Returns
|
|
68
|
+
* undefined when the skill is unknown, not user-invocable, or the registry is
|
|
69
|
+
* absent — the caller decides how to surface that.
|
|
70
|
+
*/
|
|
71
|
+
export declare function loadCardSkill(ctx: Context, name: string | undefined, cwd: string, scope: unknown): Promise<Pick<SkillDefinition, 'name' | 'provider' | 'resourceBase' | 'content'> | undefined>;
|
|
@@ -47,6 +47,8 @@ export interface KanbanCard {
|
|
|
47
47
|
id: string;
|
|
48
48
|
workspacePath: string;
|
|
49
49
|
requirement: string;
|
|
50
|
+
/** Optional skill (e.g. `grill-me`) used to drive requirement refinement. */
|
|
51
|
+
skill?: string;
|
|
50
52
|
model: string;
|
|
51
53
|
/** Provider route for the model (from the modal selection). */
|
|
52
54
|
provider?: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fonlan/dsh-task-kanban",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "DSH web plugin: a task kanban that refines requirements into phased plans, auto-executes them in git worktrees (one session per phase), and auto-merges into the main branch on completion.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -39,6 +39,9 @@
|
|
|
39
39
|
"README.md",
|
|
40
40
|
"LICENSE"
|
|
41
41
|
],
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
42
45
|
"scripts": {
|
|
43
46
|
"build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown",
|
|
44
47
|
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.client.json",
|
|
@@ -73,6 +76,7 @@
|
|
|
73
76
|
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
|
|
74
77
|
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
|
|
75
78
|
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
|
|
79
|
+
"@deepseek-ai/dsh-skill": "^0.1.0-rc.6",
|
|
76
80
|
"@deepseek-ai/schemastery": "^3.18.1",
|
|
77
81
|
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
|
|
78
82
|
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
|
|
@@ -94,6 +98,7 @@
|
|
|
94
98
|
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
|
|
95
99
|
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
|
|
96
100
|
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
|
|
101
|
+
"@deepseek-ai/dsh-skill": "^0.1.0-rc.6",
|
|
97
102
|
"@deepseek-ai/schemastery": "^3.18.1",
|
|
98
103
|
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
|
|
99
104
|
"@deepseek-ai/dsh-client-ui-sidebar": "^0.1.0-rc.6",
|
package/src/client/api.ts
CHANGED
|
@@ -28,10 +28,51 @@ async function call<T>(method: string, payload: Record<string, unknown> = {}): P
|
|
|
28
28
|
return parsed.value as T
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/** One row of the host `skill.list` catalog (same shape DSH's chat autocomplete uses). */
|
|
32
|
+
export interface SkillOption {
|
|
33
|
+
name: string
|
|
34
|
+
description: string
|
|
35
|
+
whenToUse?: string
|
|
36
|
+
modelInvocable: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Call the host gateway's `skill.list` unary RPC over the same-origin HTTP
|
|
41
|
+
* bridge (the exact wire shape DSH's chat input uses for its /-autocomplete).
|
|
42
|
+
* The requested `sessionId` scopes the catalog to that session's agent
|
|
43
|
+
* preset, which is where the filesystem skill roots are registered.
|
|
44
|
+
*/
|
|
45
|
+
export async function gatewaySkillList(sessionId: string): Promise<SkillOption[]> {
|
|
46
|
+
let response: Response
|
|
47
|
+
try {
|
|
48
|
+
response = await fetch('/api/skill.list', {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: { 'content-type': 'application/json' },
|
|
51
|
+
body: JSON.stringify({
|
|
52
|
+
type: 'client-request',
|
|
53
|
+
rpcId: `task-kanban-${Math.random().toString(36).slice(2)}`,
|
|
54
|
+
method: 'skill.list',
|
|
55
|
+
payload: { sessionId },
|
|
56
|
+
}),
|
|
57
|
+
})
|
|
58
|
+
} catch (error) {
|
|
59
|
+
throw new KanbanApiError('network', error instanceof Error ? error.message : String(error))
|
|
60
|
+
}
|
|
61
|
+
const parsed: { type?: string; result?: { ok?: boolean; value?: { skills?: SkillOption[] }; error?: { code?: string; message?: string } } } | null
|
|
62
|
+
= await response.json().catch(() => null)
|
|
63
|
+
if (!response.ok || parsed === null || parsed.type !== 'server-response' || parsed.result?.ok !== true) {
|
|
64
|
+
throw new KanbanApiError(
|
|
65
|
+
parsed?.result?.error?.code ?? 'http',
|
|
66
|
+
parsed?.result?.error?.message ?? `HTTP ${response.status}`,
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
return parsed.result.value?.skills ?? []
|
|
70
|
+
}
|
|
71
|
+
|
|
31
72
|
export const api = {
|
|
32
73
|
list: (workspacePath: string) => call<KanbanCard[]>('list', { workspacePath }),
|
|
33
|
-
create: (workspacePath: string, requirement: string, model: string, provider?: string) =>
|
|
34
|
-
call<KanbanCard>('create', { workspacePath, requirement, model, provider }),
|
|
74
|
+
create: (workspacePath: string, requirement: string, model: string, provider?: string, skill?: string) =>
|
|
75
|
+
call<KanbanCard>('create', { workspacePath, requirement, model, provider, ...(skill !== undefined && skill !== '' ? { skill } : {}) }),
|
|
35
76
|
move: (cardId: string, toLane: Lane) => call<{ ok: boolean; message?: string }>('move', { cardId, toLane }),
|
|
36
77
|
stop: (cardId: string) => call<{ ok: boolean; message?: string }>('stop', { cardId }),
|
|
37
78
|
retry: (cardId: string) => call<{ ok: boolean; message?: string }>('retry', { cardId }),
|
package/src/client/board.css
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
/* @fonlan/dsh-task-kanban board styles (kb- prefixed, plain CSS)
|
|
1
|
+
/* @fonlan/dsh-task-kanban board styles (kb- prefixed, plain CSS).
|
|
2
|
+
Surfaces use the DSH theme tokens (--dsw-alias-*) so they follow the
|
|
3
|
+
shell's selected light/dark appearance; the var() fallbacks cover shells
|
|
4
|
+
that do not define them. */
|
|
2
5
|
.kb-board {
|
|
3
6
|
height: 100%;
|
|
4
7
|
display: flex;
|
|
@@ -6,8 +9,8 @@
|
|
|
6
9
|
box-sizing: border-box;
|
|
7
10
|
padding: 12px 16px;
|
|
8
11
|
overflow: hidden;
|
|
9
|
-
color: var(--dsh-fg, #e6e6e6);
|
|
10
|
-
background: var(--dsh-bg, #141418);
|
|
12
|
+
color: var(--dsw-alias-label-primary, var(--dsh-fg, #e6e6e6));
|
|
13
|
+
background: var(--dsw-alias-bg-base, var(--dsh-bg, #141418));
|
|
11
14
|
font: 14px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
|
12
15
|
}
|
|
13
16
|
.kb-empty {
|
|
@@ -81,7 +84,7 @@
|
|
|
81
84
|
padding: 8px 10px;
|
|
82
85
|
font-weight: 600;
|
|
83
86
|
font-size: 13px;
|
|
84
|
-
color: var(--dsh-fg-soft, #b8b8c0);
|
|
87
|
+
color: var(--dsw-alias-label-secondary, var(--dsh-fg-soft, #b8b8c0));
|
|
85
88
|
border-bottom: 1px solid rgba(128, 128, 128, 0.12);
|
|
86
89
|
flex: 0 0 auto;
|
|
87
90
|
}
|
|
@@ -121,6 +124,11 @@
|
|
|
121
124
|
.kb-card-running .kb-card-badge { background: rgba(59, 130, 246, 0.3); }
|
|
122
125
|
.kb-card-error .kb-card-badge { background: rgba(239, 68, 68, 0.3); }
|
|
123
126
|
.kb-card-phase { opacity: 0.8; }
|
|
127
|
+
.kb-card-skill {
|
|
128
|
+
background: rgba(139, 92, 246, 0.22);
|
|
129
|
+
border-radius: 4px;
|
|
130
|
+
padding: 1px 6px;
|
|
131
|
+
}
|
|
124
132
|
.kb-card-model { opacity: 0.55; }
|
|
125
133
|
.kb-card-error-text { margin-top: 6px; font-size: 12px; color: #f87171; word-break: break-all; }
|
|
126
134
|
.kb-card-worktree { margin-top: 4px; font-size: 11px; opacity: 0.5; word-break: break-all; }
|
|
@@ -129,9 +137,9 @@
|
|
|
129
137
|
left: 50%;
|
|
130
138
|
bottom: 24px;
|
|
131
139
|
transform: translateX(-50%);
|
|
132
|
-
background:
|
|
133
|
-
border: 1px solid
|
|
134
|
-
color:
|
|
140
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
141
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
142
|
+
color: var(--dsw-alias-label-primary);
|
|
135
143
|
padding: 8px 16px;
|
|
136
144
|
border-radius: 8px;
|
|
137
145
|
font-size: 13px;
|
|
@@ -152,11 +160,11 @@
|
|
|
152
160
|
max-width: 90vw;
|
|
153
161
|
max-height: 85vh;
|
|
154
162
|
overflow-y: auto;
|
|
155
|
-
background:
|
|
156
|
-
border: 1px solid
|
|
163
|
+
background: var(--dsw-alias-bg-layer-1);
|
|
164
|
+
border: 1px solid var(--dsw-alias-border-l1);
|
|
157
165
|
border-radius: 12px;
|
|
158
166
|
padding: 18px;
|
|
159
|
-
color:
|
|
167
|
+
color: var(--dsw-alias-label-primary);
|
|
160
168
|
}
|
|
161
169
|
.kb-modal-wide { width: 640px; }
|
|
162
170
|
/* Task-detail bottom panel: docks at the bottom of the board page, fills the
|
|
@@ -168,11 +176,11 @@
|
|
|
168
176
|
display: flex;
|
|
169
177
|
flex-direction: column;
|
|
170
178
|
margin-top: 10px;
|
|
171
|
-
background:
|
|
172
|
-
border: 1px solid
|
|
179
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
180
|
+
border: 1px solid var(--dsw-alias-border-l1);
|
|
173
181
|
border-radius: 12px;
|
|
174
182
|
overflow: hidden;
|
|
175
|
-
color:
|
|
183
|
+
color: var(--dsw-alias-label-primary);
|
|
176
184
|
}
|
|
177
185
|
.kb-detail-panel-head {
|
|
178
186
|
display: flex;
|
|
@@ -210,6 +218,53 @@
|
|
|
210
218
|
.kb-modal-title { font-size: 16px; font-weight: 700; margin-bottom: 14px; display: flex; align-items: center; gap: 10px; }
|
|
211
219
|
.kb-field { display: block; margin-bottom: 12px; }
|
|
212
220
|
.kb-field > span { display: block; font-size: 12px; opacity: 0.75; margin-bottom: 4px; }
|
|
221
|
+
.kb-field-hint {
|
|
222
|
+
display: block;
|
|
223
|
+
font-size: 11px;
|
|
224
|
+
opacity: 0.55;
|
|
225
|
+
margin-top: 4px;
|
|
226
|
+
}
|
|
227
|
+
.kb-field-hint-skill {
|
|
228
|
+
opacity: 0.9;
|
|
229
|
+
color: #a78bfa;
|
|
230
|
+
}
|
|
231
|
+
.kb-skill-menu-wrap { position: relative; }
|
|
232
|
+
.kb-skill-menu {
|
|
233
|
+
position: absolute;
|
|
234
|
+
top: 100%;
|
|
235
|
+
left: 0;
|
|
236
|
+
right: 0;
|
|
237
|
+
z-index: 20;
|
|
238
|
+
margin: 2px 0 0;
|
|
239
|
+
padding: 4px;
|
|
240
|
+
list-style: none;
|
|
241
|
+
max-height: 180px;
|
|
242
|
+
overflow-y: auto;
|
|
243
|
+
border-radius: 8px;
|
|
244
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
245
|
+
background: var(--dsw-alias-bg-overlay);
|
|
246
|
+
color: var(--dsw-alias-label-primary);
|
|
247
|
+
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
|
|
248
|
+
}
|
|
249
|
+
.kb-skill-menu-item {
|
|
250
|
+
display: flex;
|
|
251
|
+
flex-direction: column;
|
|
252
|
+
gap: 2px;
|
|
253
|
+
padding: 6px 8px;
|
|
254
|
+
border-radius: 6px;
|
|
255
|
+
cursor: pointer;
|
|
256
|
+
font-size: 12px;
|
|
257
|
+
color: var(--dsw-alias-label-primary);
|
|
258
|
+
}
|
|
259
|
+
.kb-skill-menu-item-active { background: var(--dsw-alias-interactive-bg-hover); }
|
|
260
|
+
.kb-skill-menu-name { font-weight: 600; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
261
|
+
.kb-skill-menu-desc {
|
|
262
|
+
color: var(--dsw-alias-label-tertiary);
|
|
263
|
+
font-size: 11px;
|
|
264
|
+
white-space: nowrap;
|
|
265
|
+
overflow: hidden;
|
|
266
|
+
text-overflow: ellipsis;
|
|
267
|
+
}
|
|
213
268
|
.kb-field-row { display: flex; align-items: center; gap: 10px; justify-content: space-between; }
|
|
214
269
|
.kb-input {
|
|
215
270
|
width: 100%;
|
|
@@ -226,6 +281,14 @@
|
|
|
226
281
|
.kb-modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
|
|
227
282
|
.kb-detail-section { margin-bottom: 12px; }
|
|
228
283
|
.kb-detail-label { font-size: 12px; opacity: 0.65; margin-bottom: 4px; }
|
|
284
|
+
.kb-detail-skill {
|
|
285
|
+
display: inline-block;
|
|
286
|
+
font-size: 11px;
|
|
287
|
+
background: rgba(139, 92, 246, 0.22);
|
|
288
|
+
border-radius: 4px;
|
|
289
|
+
padding: 1px 6px;
|
|
290
|
+
margin-bottom: 4px;
|
|
291
|
+
}
|
|
229
292
|
.kb-detail-text { white-space: pre-wrap; word-break: break-all; font-size: 13px; }
|
|
230
293
|
.kb-detail-error .kb-detail-text { color: #f87171; }
|
|
231
294
|
.kb-detail-status {
|
|
@@ -280,11 +343,10 @@
|
|
|
280
343
|
font-size: 14px;
|
|
281
344
|
line-height: 22px;
|
|
282
345
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
}
|
|
346
|
+
/* Theme following is handled by the --dsw-alias-* tokens above: they change
|
|
347
|
+
value with the shell's selected light/dark appearance, so no OS-level
|
|
348
|
+
prefers-color-scheme override is needed (and one would fight the app
|
|
349
|
+
theme when the two disagree). */
|
|
288
350
|
/* Same-line placement WITHOUT moving any DOM: the sidebar foot area becomes
|
|
289
351
|
a reversed flex row, so its LAST child (the settings area) renders on the
|
|
290
352
|
LEFT and the FIRST child (footer actions = the kanban button) on the RIGHT
|
package/src/client/board.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
2
|
import type { KanbanCard, Lane } from '../shared/card'
|
|
3
3
|
import { DROP_RULES, laneOf } from '../shared/lanes'
|
|
4
|
-
import { api } from './api'
|
|
4
|
+
import { api, gatewaySkillList, type SkillOption } from './api'
|
|
5
5
|
import { exitBoard, getClient } from './kanban-state'
|
|
6
6
|
import { en, zh } from './locales'
|
|
7
7
|
import './board.css'
|
|
@@ -9,12 +9,28 @@ import './board.css'
|
|
|
9
9
|
interface BoardProps {
|
|
10
10
|
t?: (key: string, params?: Record<string, unknown>) => string
|
|
11
11
|
useWorkspaces?: (selector: (state: unknown) => unknown) => unknown
|
|
12
|
+
useSessions?: (selector: (state: unknown) => unknown) => unknown
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Session-list snapshot shape the standard `useSessions` feed carries. */
|
|
16
|
+
interface SessionListStateLike {
|
|
17
|
+
current?: string
|
|
12
18
|
}
|
|
13
19
|
|
|
14
20
|
function firstLine(text: string): string {
|
|
15
21
|
return text.split('\n').find((l) => l.trim() !== '')?.trim() ?? ''
|
|
16
22
|
}
|
|
17
23
|
|
|
24
|
+
/** The leading `/skill-name` gesture at the start of the requirement input. */
|
|
25
|
+
const SKILL_GESTURE = /^\s*\/([a-z0-9]+(?:-[a-z0-9]+)*)(?=\s|$)/
|
|
26
|
+
|
|
27
|
+
/** Extract a leading `/skill-name` token plus the cleaned remainder. */
|
|
28
|
+
function parseSkillGesture(raw: string): { skill?: string; requirement: string } {
|
|
29
|
+
const m = SKILL_GESTURE.exec(raw)
|
|
30
|
+
if (m === null) return { requirement: raw.trim() }
|
|
31
|
+
return { skill: m[1], requirement: raw.slice(m[0].length).trim() }
|
|
32
|
+
}
|
|
33
|
+
|
|
18
34
|
function statusLabel(card: KanbanCard, t: (key: string) => string): string {
|
|
19
35
|
if (card.status === 'error') {
|
|
20
36
|
switch (card.error?.kind) {
|
|
@@ -64,6 +80,9 @@ export function BoardRoot(props: BoardProps): JSX.Element {
|
|
|
64
80
|
const useWorkspaces = props.useWorkspaces
|
|
65
81
|
const wsState = useWorkspaces !== undefined ? (useWorkspaces((s: unknown) => s) as { items?: WorkspaceRow[] } | undefined) : undefined
|
|
66
82
|
const items: WorkspaceRow[] = wsState?.items ?? []
|
|
83
|
+
const useSessions = props.useSessions
|
|
84
|
+
const sessionState = useSessions !== undefined ? (useSessions((s: unknown) => s) as SessionListStateLike | undefined) : undefined
|
|
85
|
+
const currentSessionId = sessionState?.current
|
|
67
86
|
|
|
68
87
|
const [selectedPath, setSelectedPath] = useState<string>(() => {
|
|
69
88
|
try {
|
|
@@ -211,6 +230,7 @@ export function BoardRoot(props: BoardProps): JSX.Element {
|
|
|
211
230
|
t={t}
|
|
212
231
|
workspaces={items}
|
|
213
232
|
selectedPath={selectedPath}
|
|
233
|
+
sessionId={currentSessionId}
|
|
214
234
|
onClose={() => setNewTaskOpen(false)}
|
|
215
235
|
onCreated={() => {
|
|
216
236
|
setNewTaskOpen(false)
|
|
@@ -264,6 +284,7 @@ function CardView({ card, t, onToggle }: CardViewProps): JSX.Element {
|
|
|
264
284
|
<div className="kb-card-meta">
|
|
265
285
|
<span className="kb-card-badge">{badge}</span>
|
|
266
286
|
{phaseInfo !== '' && <span className="kb-card-phase">{phaseInfo}</span>}
|
|
287
|
+
{card.skill !== undefined && card.skill !== '' && <span className="kb-card-skill">/ {card.skill}</span>}
|
|
267
288
|
{card.model !== '' && <span className="kb-card-model">{card.model}</span>}
|
|
268
289
|
</div>
|
|
269
290
|
{card.status === 'error' && card.error !== undefined && (
|
|
@@ -280,18 +301,27 @@ interface NewTaskModalProps {
|
|
|
280
301
|
t: (key: string, params?: Record<string, unknown>) => string
|
|
281
302
|
workspaces: WorkspaceRow[]
|
|
282
303
|
selectedPath: string
|
|
304
|
+
/** Current session id, scoping the host skill.list catalog lookup. */
|
|
305
|
+
sessionId?: string
|
|
283
306
|
onClose: () => void
|
|
284
307
|
onCreated: () => void
|
|
285
308
|
onError: (message: string) => void
|
|
286
309
|
}
|
|
287
310
|
|
|
288
|
-
|
|
311
|
+
/** An in-progress leading `/query` token (menu-open state). */
|
|
312
|
+
const SKILL_MENU_RE = /^\s*\/([a-z0-9-]*)$/
|
|
313
|
+
|
|
314
|
+
function NewTaskModal({ t, workspaces, selectedPath, sessionId, onClose, onCreated, onError }: NewTaskModalProps): JSX.Element {
|
|
289
315
|
const [requirement, setRequirement] = useState('')
|
|
290
316
|
const [project, setProject] = useState(selectedPath)
|
|
291
317
|
const [model, setModel] = useState('')
|
|
292
318
|
const [modelProvider, setModelProvider] = useState('')
|
|
293
319
|
const [models, setModels] = useState<Array<{ provider: string; id: string; name?: string }>>([])
|
|
320
|
+
const [skills, setSkills] = useState<SkillOption[]>([])
|
|
321
|
+
const [menuQuery, setMenuQuery] = useState<string | null>(null)
|
|
322
|
+
const [menuIndex, setMenuIndex] = useState(0)
|
|
294
323
|
const [busy, setBusy] = useState(false)
|
|
324
|
+
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
|
295
325
|
|
|
296
326
|
useEffect(() => {
|
|
297
327
|
let alive = true
|
|
@@ -308,6 +338,73 @@ function NewTaskModal({ t, workspaces, selectedPath, onClose, onCreated, onError
|
|
|
308
338
|
return () => { alive = false }
|
|
309
339
|
}, [])
|
|
310
340
|
|
|
341
|
+
// Load the host skill catalog once (scoped to the current session), the
|
|
342
|
+
// same feed DSH's chat input uses for its /-autocomplete.
|
|
343
|
+
useEffect(() => {
|
|
344
|
+
if (sessionId === undefined) return
|
|
345
|
+
let alive = true
|
|
346
|
+
gatewaySkillList(sessionId)
|
|
347
|
+
.then((list) => { if (alive) setSkills(list) })
|
|
348
|
+
.catch(() => { if (alive) setSkills([]) })
|
|
349
|
+
return () => { alive = false }
|
|
350
|
+
}, [sessionId])
|
|
351
|
+
|
|
352
|
+
const matches = skills.filter((s) => s.name.startsWith(menuQuery ?? ''))
|
|
353
|
+
const menuVisible = menuQuery !== null && matches.length > 0
|
|
354
|
+
const selected = menuVisible ? matches[Math.min(menuIndex, matches.length - 1)] : undefined
|
|
355
|
+
|
|
356
|
+
/** Re-evaluate the leading-token menu state from the current value + caret. */
|
|
357
|
+
const syncMenu = (value: string, caret: number): void => {
|
|
358
|
+
const head = value.slice(0, caret)
|
|
359
|
+
const m = SKILL_MENU_RE.exec(head)
|
|
360
|
+
if (m !== null) {
|
|
361
|
+
setMenuQuery(m[1])
|
|
362
|
+
setMenuIndex(0)
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
setMenuQuery(null)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const selectSkill = (name: string): void => {
|
|
369
|
+
const el = textareaRef.current
|
|
370
|
+
const caret = el?.selectionStart ?? requirement.length
|
|
371
|
+
const head = requirement.slice(0, caret)
|
|
372
|
+
const m = SKILL_MENU_RE.exec(head)
|
|
373
|
+
// Replace the leading `/query` token with `/name `.
|
|
374
|
+
const inserted = `/${name} `
|
|
375
|
+
const newValue = m !== null ? inserted + requirement.slice(caret) : inserted + requirement
|
|
376
|
+
setRequirement(newValue)
|
|
377
|
+
setMenuQuery(null)
|
|
378
|
+
requestAnimationFrame(() => {
|
|
379
|
+
const next = textareaRef.current
|
|
380
|
+
if (next !== null) {
|
|
381
|
+
next.focus()
|
|
382
|
+
const pos = inserted.length
|
|
383
|
+
next.setSelectionRange(pos, pos)
|
|
384
|
+
}
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
|
|
389
|
+
if (!menuVisible) return
|
|
390
|
+
if (e.key === 'ArrowDown') {
|
|
391
|
+
e.preventDefault()
|
|
392
|
+
setMenuIndex((i) => (i + 1) % matches.length)
|
|
393
|
+
} else if (e.key === 'ArrowUp') {
|
|
394
|
+
e.preventDefault()
|
|
395
|
+
setMenuIndex((i) => (i - 1 + matches.length) % matches.length)
|
|
396
|
+
} else if (e.key === 'Enter') {
|
|
397
|
+
e.preventDefault()
|
|
398
|
+
if (selected !== undefined) selectSkill(selected.name)
|
|
399
|
+
} else if (e.key === 'Escape') {
|
|
400
|
+
e.preventDefault()
|
|
401
|
+
setMenuQuery(null)
|
|
402
|
+
} else if (e.key === 'Tab') {
|
|
403
|
+
e.preventDefault()
|
|
404
|
+
if (selected !== undefined) selectSkill(selected.name)
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
311
408
|
const submit = async (): Promise<void> => {
|
|
312
409
|
if (requirement.trim() === '') {
|
|
313
410
|
onError(t('requirementLabel') + ' ' + t('error'))
|
|
@@ -317,9 +414,10 @@ function NewTaskModal({ t, workspaces, selectedPath, onClose, onCreated, onError
|
|
|
317
414
|
onError(t('noWorkspace'))
|
|
318
415
|
return
|
|
319
416
|
}
|
|
417
|
+
const parsed = parseSkillGesture(requirement)
|
|
320
418
|
setBusy(true)
|
|
321
419
|
try {
|
|
322
|
-
await api.create(project, requirement
|
|
420
|
+
await api.create(project, parsed.requirement, model, modelProvider, parsed.skill)
|
|
323
421
|
onCreated()
|
|
324
422
|
} catch (error) {
|
|
325
423
|
onError(error instanceof Error ? error.message : String(error))
|
|
@@ -327,18 +425,52 @@ function NewTaskModal({ t, workspaces, selectedPath, onClose, onCreated, onError
|
|
|
327
425
|
}
|
|
328
426
|
}
|
|
329
427
|
|
|
428
|
+
const activeSkill = parseSkillGesture(requirement).skill
|
|
429
|
+
|
|
330
430
|
return (
|
|
331
431
|
<div className="kb-modal-backdrop" onClick={busy ? undefined : onClose}>
|
|
332
432
|
<div className="kb-modal" onClick={(e) => e.stopPropagation()}>
|
|
333
433
|
<div className="kb-modal-title">{t('newTask')}</div>
|
|
334
434
|
<label className="kb-field">
|
|
335
435
|
<span>{t('requirementLabel')}</span>
|
|
336
|
-
<
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
436
|
+
<div className="kb-skill-menu-wrap">
|
|
437
|
+
<textarea
|
|
438
|
+
ref={textareaRef}
|
|
439
|
+
className="kb-input kb-textarea"
|
|
440
|
+
value={requirement}
|
|
441
|
+
placeholder={t('requirementPlaceholder')}
|
|
442
|
+
onChange={(e) => {
|
|
443
|
+
const value = e.target.value
|
|
444
|
+
setRequirement(value)
|
|
445
|
+
syncMenu(value, e.target.selectionStart ?? value.length)
|
|
446
|
+
}}
|
|
447
|
+
onKeyDown={onKeyDown}
|
|
448
|
+
/>
|
|
449
|
+
{menuVisible && (
|
|
450
|
+
<ul className="kb-skill-menu" role="listbox">
|
|
451
|
+
{matches.map((s, i) => (
|
|
452
|
+
<li
|
|
453
|
+
key={s.name}
|
|
454
|
+
role="option"
|
|
455
|
+
aria-selected={i === menuIndex}
|
|
456
|
+
className={'kb-skill-menu-item' + (i === menuIndex ? ' kb-skill-menu-item-active' : '')}
|
|
457
|
+
onMouseDown={(e) => { e.preventDefault(); selectSkill(s.name) }}
|
|
458
|
+
onMouseEnter={() => setMenuIndex(i)}
|
|
459
|
+
>
|
|
460
|
+
<span className="kb-skill-menu-name">/{s.name}</span>
|
|
461
|
+
<span className="kb-skill-menu-desc">
|
|
462
|
+
{s.modelInvocable ? s.description : `${t('skillMenuUserOnly')} · ${s.description}`}
|
|
463
|
+
</span>
|
|
464
|
+
</li>
|
|
465
|
+
))}
|
|
466
|
+
</ul>
|
|
467
|
+
)}
|
|
468
|
+
</div>
|
|
469
|
+
{activeSkill !== undefined ? (
|
|
470
|
+
<span className="kb-field-hint kb-field-hint-skill">{t('skillHint', { skill: activeSkill })}</span>
|
|
471
|
+
) : (
|
|
472
|
+
<span className="kb-field-hint">{t('skillGestureHint')}</span>
|
|
473
|
+
)}
|
|
342
474
|
</label>
|
|
343
475
|
<label className="kb-field">
|
|
344
476
|
<span>{t('project')}</span>
|
|
@@ -419,6 +551,9 @@ function DetailPanel({ card, t, onClose, onChanged, onToast, onOpenSession }: De
|
|
|
419
551
|
<div className="kb-detail-panel-body">
|
|
420
552
|
<div className="kb-detail-section">
|
|
421
553
|
<div className="kb-detail-label">{t('requirement')}</div>
|
|
554
|
+
{card.skill !== undefined && card.skill !== '' && (
|
|
555
|
+
<div className="kb-detail-skill">{t('skillBadge', { skill: card.skill })}</div>
|
|
556
|
+
)}
|
|
422
557
|
<div className="kb-detail-text">{card.requirement}</div>
|
|
423
558
|
</div>
|
|
424
559
|
|