kcg-code 0.1.0 → 0.2.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/README.md +2 -0
- package/package.json +2 -1
- package/src/cli/__tests__/daemon.test.ts +84 -0
- package/src/cli/__tests__/dashboard.test.ts +170 -8
- package/src/cli/__tests__/keep-awake.test.ts +44 -0
- package/src/cli/__tests__/port.test.ts +86 -0
- package/src/cli/args.ts +69 -6
- package/src/cli/attach.ts +154 -0
- package/src/cli/commands.ts +174 -21
- package/src/cli/daemon.ts +275 -0
- package/src/cli/dashboard.tsx +876 -159
- package/src/cli/index.ts +10 -0
- package/src/cli/keep-awake.ts +148 -0
- package/src/cli/port.ts +92 -0
- package/src/cli/theme.ts +6 -6
- package/src/components/layout/AppShell.tsx +8 -3
- package/src/components/projects/HomeChat.tsx +85 -48
- package/src/components/projects/HomeRecentSessions.tsx +90 -0
- package/src/components/projects/ProjectInstructions.tsx +27 -38
- package/src/components/projects/ProjectSidePanel.tsx +382 -316
- package/src/components/sessions/AgentLogo.tsx +42 -0
- package/src/components/sessions/AgentPicker.tsx +13 -4
- package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +27 -7
- package/src/components/sessions/ContextPanel.tsx +159 -59
- package/src/components/sessions/ModelPicker.tsx +65 -27
- package/src/components/sessions/ModelSearchList.tsx +137 -57
- package/src/components/sessions/NewSessionDialog.tsx +100 -117
- package/src/components/sessions/SessionComposer.tsx +2 -1
- package/src/components/sessions/SessionHeader.tsx +1 -0
- package/src/components/sessions/SessionList.tsx +173 -66
- package/src/components/sessions/SessionRow.tsx +176 -76
- package/src/components/sessions/SessionView.tsx +17 -13
- package/src/components/sessions/__tests__/ContextPanel.test.tsx +36 -0
- package/src/components/settings/LhrRemoteAccess.tsx +20 -25
- package/src/components/settings/RemoteAccessGroup.tsx +242 -267
- package/src/components/settings/prefs.tsx +69 -1
- package/src/components/skills/InstallNotifications.tsx +4 -3
- package/src/components/skills/InstallSkillDialog.tsx +256 -99
- package/src/components/skills/SkillCards.tsx +52 -8
- package/src/components/ui/adw-dialog.tsx +114 -0
- package/src/components/ui/adw-toggle-group.tsx +75 -0
- package/src/components/ui/search-entry.tsx +70 -0
- package/src/config.ts +32 -3
- package/src/hooks/useAgentPicker.ts +6 -2
- package/src/hooks/useSessionList.ts +95 -2
- package/src/lib/__tests__/project-extensions.test.ts +32 -1
- package/src/lib/__tests__/project-overview.test.ts +15 -0
- package/src/lib/__tests__/routes.test.ts +2 -2
- package/src/lib/__tests__/session-selection.test.ts +41 -0
- package/src/lib/__tests__/skill-installs.test.ts +1 -0
- package/src/lib/__tests__/skills-catalog.test.ts +36 -8
- package/src/lib/agent-types.ts +49 -0
- package/src/lib/project-extensions.ts +66 -4
- package/src/lib/project-overview.ts +14 -8
- package/src/lib/routes.ts +13 -4
- package/src/lib/session-selection.ts +40 -0
- package/src/lib/skill-installs.ts +3 -1
- package/src/lib/skills-catalog.ts +56 -17
- package/src/pages/projects/ProjectDetailPage.tsx +13 -12
- package/src/pages/settings/SettingsPage.tsx +212 -145
- package/src/pages/settings/__tests__/SettingsPage.test.ts +11 -4
- package/src/pages/skills/SkillsPage.tsx +89 -45
- package/src/server/__tests__/app.test.ts +15 -2
- package/src/server/app.ts +13 -1
- package/src/server/routes/projects.routes.ts +14 -4
- package/src/server/routes/sessions.routes.ts +1 -1
- package/src/server/routes/skills.routes.ts +24 -5
- package/src/server/services/__tests__/claude-code.test.ts +609 -0
- package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
- package/src/server/services/__tests__/claude-skills.test.ts +63 -0
- package/src/server/services/__tests__/session-manager.test.ts +40 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +22 -3
- package/src/server/services/__tests__/skills-registry.test.ts +6 -0
- package/src/server/services/claude-code-client.ts +1093 -0
- package/src/server/services/claude-code-translate.ts +370 -0
- package/src/server/services/claude-mcp.ts +122 -0
- package/src/server/services/claude-skills.ts +102 -0
- package/src/server/services/session-manager.ts +117 -52
- package/src/server/services/skill-install-jobs.ts +21 -8
- package/src/server/services/skills-registry.ts +18 -6
- package/src/types/message.ts +1 -1
- package/src/components/projects/PanelSection.tsx +0 -102
package/src/cli/index.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { type CliArgs, helpText, parseArgs } from "./args";
|
|
5
5
|
import { runInit, runResetLock, runStart } from "./commands";
|
|
6
|
+
import { runLogs, runStatus, runStop } from "./daemon";
|
|
6
7
|
import { c, readPackageVersion, symbols } from "./theme";
|
|
7
8
|
|
|
8
9
|
export async function runCli(argv: string[]): Promise<void> {
|
|
@@ -31,6 +32,15 @@ export async function runCli(argv: string[]): Promise<void> {
|
|
|
31
32
|
case "start":
|
|
32
33
|
await runStart(args);
|
|
33
34
|
return;
|
|
35
|
+
case "stop":
|
|
36
|
+
await runStop();
|
|
37
|
+
return;
|
|
38
|
+
case "status":
|
|
39
|
+
await runStatus();
|
|
40
|
+
return;
|
|
41
|
+
case "logs":
|
|
42
|
+
await runLogs(args);
|
|
43
|
+
return;
|
|
34
44
|
}
|
|
35
45
|
}
|
|
36
46
|
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cegah OS masuk sleep selama server kcgcode berjalan — kalau mesin tidur,
|
|
3
|
+
* jaringan putus dan tunnel (kcg/lhr) ikut disconnect.
|
|
4
|
+
*
|
|
5
|
+
* Per platform:
|
|
6
|
+
* - **linux**: `systemd-inhibit --what=sleep:idle … cat`. Child `cat` membaca
|
|
7
|
+
* stdin pipe dari proses ini, jadi bila kcgcode mati (termasuk SIGKILL)
|
|
8
|
+
* pipe tertutup, `cat` keluar, dan inhibitor otomatis dilepas.
|
|
9
|
+
* - **darwin**: `caffeinate -i -s -w <pid>` — berhenti sendiri saat pid mati.
|
|
10
|
+
* - **win32**: `SetThreadExecutionState` via `bun:ffi`; state dilepas OS
|
|
11
|
+
* saat proses berakhir.
|
|
12
|
+
*
|
|
13
|
+
* Hanya mencegah sleep otomatis (idle). Layar tetap boleh mati, dan sleep
|
|
14
|
+
* karena tutup lid laptop tetap mengikuti setelan OS.
|
|
15
|
+
*/
|
|
16
|
+
import type { Subprocess } from "bun";
|
|
17
|
+
|
|
18
|
+
export interface KeepAwakeStatus {
|
|
19
|
+
active: boolean;
|
|
20
|
+
/** Mekanisme yang dipakai, mis. "systemd-inhibit". */
|
|
21
|
+
method: string | null;
|
|
22
|
+
error: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface KeepAwake {
|
|
26
|
+
status(): KeepAwakeStatus;
|
|
27
|
+
stop(): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const WHY = "kcgcode is serving remote sessions (keeps the tunnel online)";
|
|
31
|
+
|
|
32
|
+
/** Perintah inhibitor untuk platform child-process; null bila tak didukung. */
|
|
33
|
+
export function inhibitCommand(platform: NodeJS.Platform, pid: number): string[] | null {
|
|
34
|
+
if (platform === "linux") {
|
|
35
|
+
return [
|
|
36
|
+
"systemd-inhibit",
|
|
37
|
+
"--what=sleep:idle",
|
|
38
|
+
"--who=kcgcode",
|
|
39
|
+
`--why=${WHY}`,
|
|
40
|
+
"--mode=block",
|
|
41
|
+
"cat",
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
if (platform === "darwin") {
|
|
45
|
+
return ["caffeinate", "-i", "-s", "-w", String(pid)];
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function disabledKeepAwake(): KeepAwake {
|
|
51
|
+
return {
|
|
52
|
+
status: () => ({ active: false, method: null, error: "disabled (--allow-sleep)" }),
|
|
53
|
+
stop() {},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function startKeepAwake(
|
|
58
|
+
platform: NodeJS.Platform = process.platform,
|
|
59
|
+
): Promise<KeepAwake> {
|
|
60
|
+
if (platform === "win32") return startWindows();
|
|
61
|
+
|
|
62
|
+
const cmd = inhibitCommand(platform, process.pid);
|
|
63
|
+
if (!cmd) {
|
|
64
|
+
return {
|
|
65
|
+
status: () => ({ active: false, method: null, error: `unsupported on ${platform}` }),
|
|
66
|
+
stop() {},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const method = cmd[0] as string;
|
|
71
|
+
let state: KeepAwakeStatus = { active: true, method, error: null };
|
|
72
|
+
let proc: Subprocess<"pipe", "ignore", "pipe">;
|
|
73
|
+
try {
|
|
74
|
+
proc = Bun.spawn(cmd, { stdin: "pipe", stdout: "ignore", stderr: "pipe" });
|
|
75
|
+
} catch (err) {
|
|
76
|
+
const reason = (err as Error).message.includes("ENOENT")
|
|
77
|
+
? `${method} not found`
|
|
78
|
+
: (err as Error).message;
|
|
79
|
+
return {
|
|
80
|
+
status: () => ({ active: false, method, error: reason }),
|
|
81
|
+
stop() {},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let stopping = false;
|
|
86
|
+
void proc.exited.then(async (code) => {
|
|
87
|
+
if (stopping) return;
|
|
88
|
+
const stderr = (await new Response(proc.stderr).text()).trim();
|
|
89
|
+
const firstLine = stderr.split("\n")[0];
|
|
90
|
+
state = {
|
|
91
|
+
active: false,
|
|
92
|
+
method,
|
|
93
|
+
error: firstLine || `${method} exited (code ${code})`,
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
status: () => state,
|
|
99
|
+
stop() {
|
|
100
|
+
if (stopping) return;
|
|
101
|
+
stopping = true;
|
|
102
|
+
state = { active: false, method, error: null };
|
|
103
|
+
try {
|
|
104
|
+
proc.stdin.end();
|
|
105
|
+
} catch {
|
|
106
|
+
// sudah tertutup
|
|
107
|
+
}
|
|
108
|
+
proc.kill();
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const ES_CONTINUOUS = 0x80000000;
|
|
114
|
+
const ES_SYSTEM_REQUIRED = 0x00000001;
|
|
115
|
+
|
|
116
|
+
async function startWindows(): Promise<KeepAwake> {
|
|
117
|
+
const method = "SetThreadExecutionState";
|
|
118
|
+
try {
|
|
119
|
+
const { dlopen, FFIType } = await import("bun:ffi");
|
|
120
|
+
const lib = dlopen("kernel32.dll", {
|
|
121
|
+
SetThreadExecutionState: { args: [FFIType.u32], returns: FFIType.u32 },
|
|
122
|
+
});
|
|
123
|
+
const set = lib.symbols.SetThreadExecutionState;
|
|
124
|
+
if (set(ES_CONTINUOUS | ES_SYSTEM_REQUIRED) === 0) {
|
|
125
|
+
lib.close();
|
|
126
|
+
return {
|
|
127
|
+
status: () => ({ active: false, method, error: `${method} failed` }),
|
|
128
|
+
stop() {},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
let active = true;
|
|
132
|
+
return {
|
|
133
|
+
status: () => ({ active, method, error: null }),
|
|
134
|
+
stop() {
|
|
135
|
+
if (!active) return;
|
|
136
|
+
active = false;
|
|
137
|
+
set(ES_CONTINUOUS);
|
|
138
|
+
lib.close();
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
} catch (err) {
|
|
142
|
+
const error = (err as Error).message;
|
|
143
|
+
return {
|
|
144
|
+
status: () => ({ active: false, method, error }),
|
|
145
|
+
stop() {},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
}
|
package/src/cli/port.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pemilihan port HTTP saat startup.
|
|
3
|
+
*
|
|
4
|
+
* Prioritas: `--port` > `KCG_PORT` > `port` di config > 3000.
|
|
5
|
+
* Port eksplisit (flag/env) yang terpakai → error; port dari config/default
|
|
6
|
+
* yang terpakai → otomatis pindah ke port kosong berikutnya.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_PORT = 3000;
|
|
10
|
+
/** Jumlah port berurutan yang dicoba saat fallback. */
|
|
11
|
+
const SCAN_ATTEMPTS = 50;
|
|
12
|
+
|
|
13
|
+
export type PortSource = "flag" | "env" | "config" | "default";
|
|
14
|
+
|
|
15
|
+
const CONNECT_TIMEOUT_MS = 1000;
|
|
16
|
+
|
|
17
|
+
/** true bila ada yang sudah listen (koneksi TCP diterima). */
|
|
18
|
+
async function acceptsConnection(hostname: string, port: number): Promise<boolean> {
|
|
19
|
+
// Alamat wildcard tidak bisa dituju — cek lewat loopback.
|
|
20
|
+
const host = hostname === "0.0.0.0" ? "127.0.0.1" : hostname === "::" ? "::1" : hostname;
|
|
21
|
+
const attempt = Bun.connect({
|
|
22
|
+
hostname: host,
|
|
23
|
+
port,
|
|
24
|
+
socket: { data() {}, open: (s) => void s.end() },
|
|
25
|
+
}).then(
|
|
26
|
+
(s) => {
|
|
27
|
+
s.end();
|
|
28
|
+
return true;
|
|
29
|
+
},
|
|
30
|
+
() => false,
|
|
31
|
+
);
|
|
32
|
+
const timeout = Bun.sleep(CONNECT_TIMEOUT_MS).then(() => false);
|
|
33
|
+
return Promise.race([attempt, timeout]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Cek port bisa dipakai. Probe bind saja tidak cukup: setelah server kcgcode
|
|
38
|
+
* berjalan di proses ini, bind ke port yang sudah dipakai proses lain bisa
|
|
39
|
+
* "berhasil" (SO_REUSEPORT). Jadi cek koneksi TCP dulu, lalu probe bind untuk
|
|
40
|
+
* port yang terpakai tapi tidak menerima koneksi (mis. butuh root).
|
|
41
|
+
*/
|
|
42
|
+
export async function portIsFree(hostname: string, port: number): Promise<boolean> {
|
|
43
|
+
if (port === 0) return true;
|
|
44
|
+
if (await acceptsConnection(hostname, port)) return false;
|
|
45
|
+
try {
|
|
46
|
+
const probe = Bun.serve({ hostname, port, reusePort: false, fetch: () => new Response() });
|
|
47
|
+
probe.stop(true);
|
|
48
|
+
return true;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Port yang diminta beserta asalnya. `envPort` dibaca sebelum override `--port`. */
|
|
55
|
+
export function requestedPort(opts: { flag?: number; env?: string; config?: number }): {
|
|
56
|
+
port: number;
|
|
57
|
+
source: PortSource;
|
|
58
|
+
} {
|
|
59
|
+
if (opts.flag !== undefined) return { port: opts.flag, source: "flag" };
|
|
60
|
+
if (opts.env !== undefined && opts.env.trim() !== "") {
|
|
61
|
+
const n = Number(opts.env);
|
|
62
|
+
if (Number.isInteger(n) && n > 0 && n <= 65535) return { port: n, source: "env" };
|
|
63
|
+
}
|
|
64
|
+
if (opts.config !== undefined) return { port: opts.config, source: "config" };
|
|
65
|
+
return { port: DEFAULT_PORT, source: "default" };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type PortChoice =
|
|
69
|
+
| { ok: true; port: number; fallbackFrom: number | null }
|
|
70
|
+
| { ok: false; error: string };
|
|
71
|
+
|
|
72
|
+
/** Pilih port final: pakai yang diminta, atau cari pengganti bila boleh. */
|
|
73
|
+
export async function choosePort(
|
|
74
|
+
hostname: string,
|
|
75
|
+
req: { port: number; source: PortSource },
|
|
76
|
+
isFree: (hostname: string, port: number) => boolean | Promise<boolean> = portIsFree,
|
|
77
|
+
): Promise<PortChoice> {
|
|
78
|
+
if (await isFree(hostname, req.port)) return { ok: true, port: req.port, fallbackFrom: null };
|
|
79
|
+
|
|
80
|
+
if (req.source === "flag" || req.source === "env") {
|
|
81
|
+
const via = req.source === "flag" ? "--port" : "KCG_PORT";
|
|
82
|
+
return { ok: false, error: `Port ${req.port} (from ${via}) is already in use on ${hostname}.` };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (let p = req.port + 1; p < req.port + 1 + SCAN_ATTEMPTS && p <= 65535; p++) {
|
|
86
|
+
if (await isFree(hostname, p)) return { ok: true, port: p, fallbackFrom: req.port };
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
error: `Port ${req.port} and the next ${SCAN_ATTEMPTS} ports are in use on ${hostname}.`,
|
|
91
|
+
};
|
|
92
|
+
}
|
package/src/cli/theme.ts
CHANGED
|
@@ -55,12 +55,12 @@ export async function readPackageVersion(): Promise<string> {
|
|
|
55
55
|
/** Banner ASCII "KCG". */
|
|
56
56
|
export function logoLines(): string[] {
|
|
57
57
|
return [
|
|
58
|
-
"██╗ ██╗ ██████╗ ",
|
|
59
|
-
"██║ ██╔╝██╔════╝ ",
|
|
60
|
-
"█████╔╝ ██║ ",
|
|
61
|
-
"██╔═██╗ ██║ ",
|
|
62
|
-
"██║ ██╗╚██████╗ ",
|
|
63
|
-
"╚═╝ ╚═╝ ╚═════╝ ",
|
|
58
|
+
"██╗ ██╗ ██████╗ ██████╗ ",
|
|
59
|
+
"██║ ██╔╝██╔════╝ ██╔════╝ ",
|
|
60
|
+
"█████╔╝ ██║ ██║ ███╗",
|
|
61
|
+
"██╔═██╗ ██║ ██║ ██║",
|
|
62
|
+
"██║ ██╗╚██████╗ ╚██████╔╝",
|
|
63
|
+
"╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ",
|
|
64
64
|
];
|
|
65
65
|
}
|
|
66
66
|
|
|
@@ -95,8 +95,9 @@ export function AppShell() {
|
|
|
95
95
|
tiap kolom scroll sendiri; lebih sempit: halaman
|
|
96
96
|
scroll biasa (panel samping ada di Sheet). */
|
|
97
97
|
"mx-auto w-full max-w-6xl px-4 pt-2 pb-12 sm:px-6 sm:pt-4 sm:pb-16 lg:h-full lg:pb-0"
|
|
98
|
-
: route.name === "skills"
|
|
99
|
-
? /* Skills: grid kartu 2 kolom
|
|
98
|
+
: route.name === "skills" || route.name === "settings"
|
|
99
|
+
? /* Skills: grid kartu 2 kolom; Settings: daftar navigasi
|
|
100
|
+
+ isi berdampingan (lg+) — butuh ruang lebih lebar. */
|
|
100
101
|
"mx-auto w-full max-w-5xl px-4 pt-2 pb-12 sm:px-6 sm:pt-4 sm:pb-16"
|
|
101
102
|
: "mx-auto w-full max-w-3xl px-4 pt-2 pb-12 sm:px-6 sm:pt-4 sm:pb-16"
|
|
102
103
|
}
|
|
@@ -107,7 +108,11 @@ export function AppShell() {
|
|
|
107
108
|
)}
|
|
108
109
|
{route.name === "skills" && <SkillsPage />}
|
|
109
110
|
{route.name === "settings" && (
|
|
110
|
-
<SettingsPage
|
|
111
|
+
<SettingsPage
|
|
112
|
+
section={route.section}
|
|
113
|
+
anchor={route.anchor}
|
|
114
|
+
root={route.root === true}
|
|
115
|
+
/>
|
|
111
116
|
)}
|
|
112
117
|
{route.name === "not-found" && <NotFoundView />}
|
|
113
118
|
</div>
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Chat homepage: headline acak + logo, lalu composer. Di bawah kotak input
|
|
3
|
-
* ada baris polos (tanpa border): pemilih Project di kiri, model di
|
|
3
|
+
* ada baris polos (tanpa border): pemilih Project di kiri, agent + model di
|
|
4
|
+
* kanan (satu trigger `ModelPicker` — model bergantung pada agent).
|
|
5
|
+
*
|
|
6
|
+
* Tata letak: semuanya di tengah layar. Desktop menampilkan daftar Session
|
|
7
|
+
* terbaru (`HomeRecentSessions`) di bawah composer; di HP daftar itu
|
|
8
|
+
* disembunyikan dan logo ditumpuk di atas headline.
|
|
4
9
|
*
|
|
5
10
|
* Kirim = buat Session baru di Project terpilih (`POST /api/sessions`),
|
|
6
11
|
* titipkan teks sebagai prompt tertunda (`lib/pending-prompt.ts`), lalu
|
|
@@ -20,6 +25,7 @@ import {
|
|
|
20
25
|
SendHorizontalIcon,
|
|
21
26
|
} from "lucide-react";
|
|
22
27
|
import { useEffect, useRef, useState } from "react";
|
|
28
|
+
import { HomeRecentSessions } from "@/components/projects/HomeRecentSessions";
|
|
23
29
|
import { AgentPicker } from "@/components/sessions/AgentPicker";
|
|
24
30
|
import { ModelPicker } from "@/components/sessions/ModelPicker";
|
|
25
31
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
@@ -41,6 +47,7 @@ import {
|
|
|
41
47
|
} from "@/components/ui/input-group";
|
|
42
48
|
import { Spinner } from "@/components/ui/spinner";
|
|
43
49
|
import { useRouter } from "@/hooks/useRouter";
|
|
50
|
+
import { readPreferredAgentType, storePreferredAgentType } from "@/lib/agent-types";
|
|
44
51
|
import { ApiError, apiFetch } from "@/lib/api";
|
|
45
52
|
import { notifyDataChanged } from "@/lib/data-events";
|
|
46
53
|
import { pickHeadline } from "@/lib/headlines";
|
|
@@ -49,7 +56,7 @@ import type { ProjectOverview } from "@/lib/project-overview";
|
|
|
49
56
|
import { sessionPath } from "@/lib/routes";
|
|
50
57
|
import { seedSession } from "@/lib/session-handoff";
|
|
51
58
|
import logo from "@/logo.svg";
|
|
52
|
-
import type { Session, SessionModel } from "@/types";
|
|
59
|
+
import type { AgentType, Session, SessionModel } from "@/types";
|
|
53
60
|
|
|
54
61
|
const LAST_PROJECT_KEY = "kcg-home-project";
|
|
55
62
|
|
|
@@ -70,6 +77,8 @@ export function HomeChat({ overviews, onNewProject }: HomeChatProps) {
|
|
|
70
77
|
const [headline] = useState(() => pickHeadline());
|
|
71
78
|
const [text, setText] = useState("");
|
|
72
79
|
const [projectId, setProjectId] = useState<string | null>(readLastProject);
|
|
80
|
+
/** Backend Session baru (OpenCode / Claude Code), diingat per perangkat. */
|
|
81
|
+
const [agentType, setAgentType] = useState<AgentType>(readPreferredAgentType);
|
|
73
82
|
const [sending, setSending] = useState(false);
|
|
74
83
|
const [error, setError] = useState<string | null>(null);
|
|
75
84
|
/** Model & agent untuk Session baru; null = default opencode. */
|
|
@@ -78,8 +87,9 @@ export function HomeChat({ overviews, onNewProject }: HomeChatProps) {
|
|
|
78
87
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
|
79
88
|
|
|
80
89
|
// Project tersimpan sudah dihapus / belum ada -> pakai yang paling baru aktif.
|
|
81
|
-
const
|
|
82
|
-
overviews.find((o) => o.project.id === projectId)
|
|
90
|
+
const selectedOverview =
|
|
91
|
+
overviews.find((o) => o.project.id === projectId) ?? overviews[0] ?? null;
|
|
92
|
+
const selected = selectedOverview?.project ?? null;
|
|
83
93
|
|
|
84
94
|
useEffect(() => {
|
|
85
95
|
textareaRef.current?.focus();
|
|
@@ -96,6 +106,18 @@ export function HomeChat({ overviews, onNewProject }: HomeChatProps) {
|
|
|
96
106
|
requestAnimationFrame(() => textareaRef.current?.focus());
|
|
97
107
|
};
|
|
98
108
|
|
|
109
|
+
const chooseAgentType = (next: AgentType) => {
|
|
110
|
+
if (next !== agentType) {
|
|
111
|
+
// Model & mode berasal dari backend masing-masing.
|
|
112
|
+
setModel(null);
|
|
113
|
+
setAgent(null);
|
|
114
|
+
}
|
|
115
|
+
setAgentType(next);
|
|
116
|
+
storePreferredAgentType(next);
|
|
117
|
+
// Tanpa fokus ke textarea: dipanggil dari dialog ModelPicker yang masih
|
|
118
|
+
// terbuka (fokus terkunci di dialog sampai ditutup).
|
|
119
|
+
};
|
|
120
|
+
|
|
99
121
|
const canSend = selected !== null && text.trim() !== "" && !sending;
|
|
100
122
|
|
|
101
123
|
const submit = async (event?: React.FormEvent<HTMLFormElement>) => {
|
|
@@ -106,7 +128,7 @@ export function HomeChat({ overviews, onNewProject }: HomeChatProps) {
|
|
|
106
128
|
try {
|
|
107
129
|
const res = await apiFetch("/api/sessions", {
|
|
108
130
|
method: "POST",
|
|
109
|
-
body: JSON.stringify({ agentType
|
|
131
|
+
body: JSON.stringify({ agentType, projectId: selected.id, model, agent }),
|
|
110
132
|
});
|
|
111
133
|
const { session } = (await res.json()) as { session: Session };
|
|
112
134
|
setPendingPrompt(session.id, text);
|
|
@@ -123,9 +145,13 @@ export function HomeChat({ overviews, onNewProject }: HomeChatProps) {
|
|
|
123
145
|
|
|
124
146
|
return (
|
|
125
147
|
<div className="mx-auto flex w-full max-w-2xl flex-1 flex-col justify-center gap-6 pb-[10dvh]">
|
|
126
|
-
|
|
148
|
+
{/* HP: logo di atas headline (tengah); desktop: berdampingan. */}
|
|
149
|
+
<div
|
|
150
|
+
data-vt-name="home-headline"
|
|
151
|
+
className="flex flex-col items-center justify-center gap-3 px-2 sm:flex-row"
|
|
152
|
+
>
|
|
127
153
|
<img src={logo} alt="" aria-hidden className="size-10 shrink-0 sm:size-12" />
|
|
128
|
-
<h1 className="text-balance text-2xl font-semibold tracking-tight sm:text-3xl">
|
|
154
|
+
<h1 className="text-balance text-center text-2xl font-semibold tracking-tight sm:text-left sm:text-3xl">
|
|
129
155
|
{headline}
|
|
130
156
|
</h1>
|
|
131
157
|
</div>
|
|
@@ -162,6 +188,7 @@ export function HomeChat({ overviews, onNewProject }: HomeChatProps) {
|
|
|
162
188
|
{selected && (
|
|
163
189
|
<AgentPicker
|
|
164
190
|
projectId={selected.id}
|
|
191
|
+
agentType={agentType}
|
|
165
192
|
sessionId={null}
|
|
166
193
|
agent={agent}
|
|
167
194
|
onChanged={setAgent}
|
|
@@ -181,57 +208,63 @@ export function HomeChat({ overviews, onNewProject }: HomeChatProps) {
|
|
|
181
208
|
</InputGroupAddon>
|
|
182
209
|
</InputGroup>
|
|
183
210
|
|
|
184
|
-
{/* Section 2 — konteks Session baru: Project (kiri) & model
|
|
185
|
-
tanpa latar (hanya border container luar). */}
|
|
211
|
+
{/* Section 2 — konteks Session baru: Project (kiri) & agent + model
|
|
212
|
+
(kanan), tanpa latar (hanya border container luar). */}
|
|
186
213
|
<div className="flex min-w-0 items-center justify-between gap-2 px-1.5 py-1">
|
|
187
|
-
<
|
|
188
|
-
<
|
|
189
|
-
<
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
<
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
<
|
|
208
|
-
|
|
209
|
-
<span className="
|
|
210
|
-
|
|
211
|
-
|
|
214
|
+
<div className="flex min-w-0 items-center">
|
|
215
|
+
<DropdownMenu>
|
|
216
|
+
<DropdownMenuTrigger asChild>
|
|
217
|
+
<Button
|
|
218
|
+
type="button"
|
|
219
|
+
variant="ghost"
|
|
220
|
+
size="sm"
|
|
221
|
+
className="h-9 min-w-0 gap-1.5 px-2 font-normal text-muted-foreground hover:text-foreground"
|
|
222
|
+
aria-label={`Project: ${selected?.name ?? "none"}. Change project`}
|
|
223
|
+
disabled={sending}
|
|
224
|
+
>
|
|
225
|
+
<FolderIcon data-icon="inline-start" />
|
|
226
|
+
<span className="truncate">{selected?.name ?? "Select project"}</span>
|
|
227
|
+
<ChevronDownIcon data-icon="inline-end" />
|
|
228
|
+
</Button>
|
|
229
|
+
</DropdownMenuTrigger>
|
|
230
|
+
<DropdownMenuContent align="start" className="max-h-72 w-64">
|
|
231
|
+
<DropdownMenuLabel>Project</DropdownMenuLabel>
|
|
232
|
+
<DropdownMenuGroup>
|
|
233
|
+
{overviews.map(({ project }) => (
|
|
234
|
+
<DropdownMenuItem key={project.id} onSelect={() => choose(project.id)}>
|
|
235
|
+
<FolderIcon />
|
|
236
|
+
<span className="flex min-w-0 flex-1 flex-col">
|
|
237
|
+
<span className="truncate">{project.name}</span>
|
|
238
|
+
<span className="truncate text-xs text-muted-foreground">
|
|
239
|
+
{project.path}
|
|
240
|
+
</span>
|
|
212
241
|
</span>
|
|
213
|
-
|
|
214
|
-
|
|
242
|
+
{project.id === selected?.id && <CheckIcon />}
|
|
243
|
+
</DropdownMenuItem>
|
|
244
|
+
))}
|
|
245
|
+
</DropdownMenuGroup>
|
|
246
|
+
<DropdownMenuSeparator />
|
|
247
|
+
<DropdownMenuGroup>
|
|
248
|
+
<DropdownMenuItem onSelect={onNewProject}>
|
|
249
|
+
<FolderPlusIcon />
|
|
250
|
+
New project
|
|
215
251
|
</DropdownMenuItem>
|
|
216
|
-
|
|
217
|
-
</
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
<DropdownMenuItem onSelect={onNewProject}>
|
|
221
|
-
<FolderPlusIcon />
|
|
222
|
-
New project
|
|
223
|
-
</DropdownMenuItem>
|
|
224
|
-
</DropdownMenuGroup>
|
|
225
|
-
</DropdownMenuContent>
|
|
226
|
-
</DropdownMenu>
|
|
252
|
+
</DropdownMenuGroup>
|
|
253
|
+
</DropdownMenuContent>
|
|
254
|
+
</DropdownMenu>
|
|
255
|
+
</div>
|
|
227
256
|
|
|
228
257
|
{selected && (
|
|
229
|
-
<div className="flex
|
|
258
|
+
<div className="flex max-w-[60%] shrink-0 justify-end">
|
|
259
|
+
{/* Agent dipilih di dialog yang sama dengan model; trigger
|
|
260
|
+
menampilkan logo agent + nama model. */}
|
|
230
261
|
<ModelPicker
|
|
231
262
|
projectId={selected.id}
|
|
263
|
+
agentType={agentType}
|
|
232
264
|
sessionId={null}
|
|
233
265
|
model={model}
|
|
234
266
|
onChanged={setModel}
|
|
267
|
+
onAgentTypeChange={chooseAgentType}
|
|
235
268
|
variant="inline"
|
|
236
269
|
/>
|
|
237
270
|
</div>
|
|
@@ -245,6 +278,10 @@ export function HomeChat({ overviews, onNewProject }: HomeChatProps) {
|
|
|
245
278
|
</Alert>
|
|
246
279
|
)}
|
|
247
280
|
</form>
|
|
281
|
+
|
|
282
|
+
{selectedOverview && (
|
|
283
|
+
<HomeRecentSessions overview={selectedOverview} className="hidden sm:mt-6 sm:flex" />
|
|
284
|
+
)}
|
|
248
285
|
</div>
|
|
249
286
|
);
|
|
250
287
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daftar "lanjutkan" di homepage: beberapa Session terbaru milik Project yang
|
|
3
|
+
* sedang dipilih di composer (`HomeChat`). Ringkas — titik status, judul,
|
|
4
|
+
* logo agent, waktu relatif — plus tautan ke daftar Session lengkap.
|
|
5
|
+
*
|
|
6
|
+
* Datanya sudah ada di `ProjectOverview.recentSessions`, jadi tidak ada fetch.
|
|
7
|
+
*/
|
|
8
|
+
import { ArrowRightIcon } from "lucide-react";
|
|
9
|
+
import { AgentLogo } from "@/components/sessions/AgentLogo";
|
|
10
|
+
import { useRouter } from "@/hooks/useRouter";
|
|
11
|
+
import { formatRelativeTime, type ProjectOverview } from "@/lib/project-overview";
|
|
12
|
+
import { projectPath, sessionPath } from "@/lib/routes";
|
|
13
|
+
import { SESSION_STATUS_DOT, SESSION_STATUS_LABEL } from "@/lib/session-status";
|
|
14
|
+
import { displaySessionTitle } from "@/lib/session-title";
|
|
15
|
+
import { cn } from "@/lib/utils";
|
|
16
|
+
|
|
17
|
+
export interface HomeRecentSessionsProps {
|
|
18
|
+
overview: ProjectOverview;
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function HomeRecentSessions({ overview, className }: HomeRecentSessionsProps) {
|
|
23
|
+
const { navigate } = useRouter();
|
|
24
|
+
const { project, recentSessions, sessionCount } = overview;
|
|
25
|
+
if (recentSessions.length === 0) return null;
|
|
26
|
+
|
|
27
|
+
// Tautan biasa (bukan tombol) agar buka-di-tab-baru tetap berfungsi.
|
|
28
|
+
const go = (e: React.MouseEvent<HTMLAnchorElement>, href: string) => {
|
|
29
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
|
30
|
+
e.preventDefault();
|
|
31
|
+
navigate(href);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const allHref = projectPath(project.id);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<section
|
|
38
|
+
aria-label={`Recent sessions in ${project.name}`}
|
|
39
|
+
className={cn("flex flex-col", className)}
|
|
40
|
+
>
|
|
41
|
+
{/* Label + garis rambut: menandai section terpisah dari composer di
|
|
42
|
+
atasnya (bukan kelanjutan tray Project/model). */}
|
|
43
|
+
<div className="flex items-center gap-3 px-3 pb-2">
|
|
44
|
+
<h2 className="shrink-0 text-xs font-medium text-muted-foreground">Recent sessions</h2>
|
|
45
|
+
<span aria-hidden className="h-px flex-1 bg-border" />
|
|
46
|
+
{sessionCount > recentSessions.length && (
|
|
47
|
+
<a
|
|
48
|
+
href={allHref}
|
|
49
|
+
onClick={(e) => go(e, allHref)}
|
|
50
|
+
className="flex shrink-0 items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
|
51
|
+
>
|
|
52
|
+
View all {sessionCount}
|
|
53
|
+
<ArrowRightIcon className="size-3" />
|
|
54
|
+
</a>
|
|
55
|
+
)}
|
|
56
|
+
</div>
|
|
57
|
+
<ul className="flex flex-col">
|
|
58
|
+
{recentSessions.map((session) => {
|
|
59
|
+
const href = sessionPath(project.id, session.id);
|
|
60
|
+
const title = displaySessionTitle(session.title);
|
|
61
|
+
return (
|
|
62
|
+
<li key={session.id}>
|
|
63
|
+
<a
|
|
64
|
+
href={href}
|
|
65
|
+
onClick={(e) => go(e, href)}
|
|
66
|
+
aria-label={`Open session ${title}, ${SESSION_STATUS_LABEL[session.status]}`}
|
|
67
|
+
className="group flex min-h-10 items-center gap-3 rounded-lg px-3 py-2 transition-colors hover:bg-muted/50 focus-visible:bg-muted/50 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"
|
|
68
|
+
>
|
|
69
|
+
<span
|
|
70
|
+
aria-hidden
|
|
71
|
+
className={cn("size-2 shrink-0 rounded-full", SESSION_STATUS_DOT[session.status])}
|
|
72
|
+
/>
|
|
73
|
+
<span className="min-w-0 flex-1 truncate text-sm text-muted-foreground group-hover:text-foreground group-focus-visible:text-foreground">
|
|
74
|
+
{title}
|
|
75
|
+
</span>
|
|
76
|
+
<AgentLogo
|
|
77
|
+
agentType={session.agentType}
|
|
78
|
+
className="size-3.5 text-muted-foreground"
|
|
79
|
+
/>
|
|
80
|
+
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
|
81
|
+
{formatRelativeTime(session.updatedAt)}
|
|
82
|
+
</span>
|
|
83
|
+
</a>
|
|
84
|
+
</li>
|
|
85
|
+
);
|
|
86
|
+
})}
|
|
87
|
+
</ul>
|
|
88
|
+
</section>
|
|
89
|
+
);
|
|
90
|
+
}
|