kcg-code 0.1.0 → 0.2.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.
Files changed (83) hide show
  1. package/README.md +2 -0
  2. package/package.json +2 -1
  3. package/src/cli/__tests__/daemon.test.ts +84 -0
  4. package/src/cli/__tests__/dashboard.test.ts +170 -8
  5. package/src/cli/__tests__/keep-awake.test.ts +44 -0
  6. package/src/cli/__tests__/port.test.ts +86 -0
  7. package/src/cli/args.ts +69 -6
  8. package/src/cli/attach.ts +154 -0
  9. package/src/cli/commands.ts +174 -21
  10. package/src/cli/daemon.ts +275 -0
  11. package/src/cli/dashboard.tsx +880 -159
  12. package/src/cli/index.ts +10 -0
  13. package/src/cli/ink-react.ts +26 -0
  14. package/src/cli/keep-awake.ts +148 -0
  15. package/src/cli/port.ts +92 -0
  16. package/src/cli/theme.ts +6 -6
  17. package/src/components/layout/AppShell.tsx +8 -3
  18. package/src/components/projects/HomeChat.tsx +85 -48
  19. package/src/components/projects/HomeRecentSessions.tsx +90 -0
  20. package/src/components/projects/ProjectInstructions.tsx +27 -38
  21. package/src/components/projects/ProjectSidePanel.tsx +382 -316
  22. package/src/components/sessions/AgentLogo.tsx +42 -0
  23. package/src/components/sessions/AgentPicker.tsx +13 -4
  24. package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +27 -7
  25. package/src/components/sessions/ContextPanel.tsx +159 -59
  26. package/src/components/sessions/ModelPicker.tsx +65 -27
  27. package/src/components/sessions/ModelSearchList.tsx +137 -57
  28. package/src/components/sessions/NewSessionDialog.tsx +100 -117
  29. package/src/components/sessions/SessionComposer.tsx +2 -1
  30. package/src/components/sessions/SessionHeader.tsx +1 -0
  31. package/src/components/sessions/SessionList.tsx +173 -66
  32. package/src/components/sessions/SessionRow.tsx +176 -76
  33. package/src/components/sessions/SessionView.tsx +17 -13
  34. package/src/components/sessions/__tests__/ContextPanel.test.tsx +36 -0
  35. package/src/components/settings/LhrRemoteAccess.tsx +20 -25
  36. package/src/components/settings/RemoteAccessGroup.tsx +242 -267
  37. package/src/components/settings/prefs.tsx +69 -1
  38. package/src/components/skills/InstallNotifications.tsx +4 -3
  39. package/src/components/skills/InstallSkillDialog.tsx +256 -99
  40. package/src/components/skills/SkillCards.tsx +52 -8
  41. package/src/components/ui/adw-dialog.tsx +114 -0
  42. package/src/components/ui/adw-toggle-group.tsx +75 -0
  43. package/src/components/ui/search-entry.tsx +70 -0
  44. package/src/config.ts +32 -3
  45. package/src/hooks/useAgentPicker.ts +6 -2
  46. package/src/hooks/useSessionList.ts +95 -2
  47. package/src/lib/__tests__/project-extensions.test.ts +32 -1
  48. package/src/lib/__tests__/project-overview.test.ts +15 -0
  49. package/src/lib/__tests__/routes.test.ts +2 -2
  50. package/src/lib/__tests__/session-selection.test.ts +41 -0
  51. package/src/lib/__tests__/skill-installs.test.ts +1 -0
  52. package/src/lib/__tests__/skills-catalog.test.ts +36 -8
  53. package/src/lib/agent-types.ts +49 -0
  54. package/src/lib/project-extensions.ts +66 -4
  55. package/src/lib/project-overview.ts +14 -8
  56. package/src/lib/routes.ts +13 -4
  57. package/src/lib/session-selection.ts +40 -0
  58. package/src/lib/skill-installs.ts +3 -1
  59. package/src/lib/skills-catalog.ts +56 -17
  60. package/src/pages/projects/ProjectDetailPage.tsx +13 -12
  61. package/src/pages/settings/SettingsPage.tsx +212 -145
  62. package/src/pages/settings/__tests__/SettingsPage.test.ts +11 -4
  63. package/src/pages/skills/SkillsPage.tsx +89 -45
  64. package/src/server/__tests__/app.test.ts +15 -2
  65. package/src/server/app.ts +13 -1
  66. package/src/server/routes/projects.routes.ts +14 -4
  67. package/src/server/routes/sessions.routes.ts +1 -1
  68. package/src/server/routes/skills.routes.ts +24 -5
  69. package/src/server/services/__tests__/claude-code.test.ts +609 -0
  70. package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
  71. package/src/server/services/__tests__/claude-skills.test.ts +63 -0
  72. package/src/server/services/__tests__/session-manager.test.ts +40 -0
  73. package/src/server/services/__tests__/skill-install-jobs.test.ts +22 -3
  74. package/src/server/services/__tests__/skills-registry.test.ts +6 -0
  75. package/src/server/services/claude-code-client.ts +1093 -0
  76. package/src/server/services/claude-code-translate.ts +370 -0
  77. package/src/server/services/claude-mcp.ts +122 -0
  78. package/src/server/services/claude-skills.ts +102 -0
  79. package/src/server/services/session-manager.ts +117 -52
  80. package/src/server/services/skill-install-jobs.ts +21 -8
  81. package/src/server/services/skills-registry.ts +18 -6
  82. package/src/types/message.ts +1 -1
  83. 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,26 @@
1
+ /**
2
+ * React yang sama dengan yang dipakai renderer Ink.
3
+ *
4
+ * Pada install global (`bun i -g kcg-code`), `ink` bisa di-hoist ke
5
+ * node_modules global bersama React milik paket lain, sementara kcg-code
6
+ * mendapat salinan React sendiri (`kcg-code/node_modules/react`). Hook dari
7
+ * salinan itu tidak dikenali reconciler Ink → "Invalid hook call".
8
+ *
9
+ * Solusinya: ambil React dari lokasi `react-reconciler` milik Ink, sehingga
10
+ * hook dan reconciler selalu memakai instance yang sama.
11
+ */
12
+ import { createRequire } from "node:module";
13
+ import * as LocalReact from "react";
14
+
15
+ function resolveInkReact(): typeof LocalReact {
16
+ try {
17
+ const inkRequire = createRequire(Bun.resolveSync("ink", import.meta.dir));
18
+ const reconcilerRequire = createRequire(inkRequire.resolve("react-reconciler"));
19
+ return reconcilerRequire("react") as typeof LocalReact;
20
+ } catch {
21
+ // Layout tak terduga — pakai React milik paket ini.
22
+ return LocalReact;
23
+ }
24
+ }
25
+
26
+ export const InkReact = resolveInkReact();
@@ -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
+ }
@@ -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 butuh ruang lebih lebar. */
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 section={route.section} anchor={route.anchor} />
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 kanan.
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 selected =
82
- overviews.find((o) => o.project.id === projectId)?.project ?? overviews[0]?.project ?? null;
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: "opencode", projectId: selected.id, model, agent }),
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
- <div data-vt-name="home-headline" className="flex items-center justify-center gap-3 px-2">
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 (kanan),
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
- <DropdownMenu>
188
- <DropdownMenuTrigger asChild>
189
- <Button
190
- type="button"
191
- variant="ghost"
192
- size="sm"
193
- className="h-9 min-w-0 max-w-[55%] gap-1.5 px-2 font-normal text-muted-foreground hover:text-foreground"
194
- aria-label={`Project: ${selected?.name ?? "none"}. Change project`}
195
- disabled={sending}
196
- >
197
- <FolderIcon data-icon="inline-start" />
198
- <span className="truncate">{selected?.name ?? "Select project"}</span>
199
- <ChevronDownIcon data-icon="inline-end" />
200
- </Button>
201
- </DropdownMenuTrigger>
202
- <DropdownMenuContent align="start" className="max-h-72 w-64">
203
- <DropdownMenuLabel>Project</DropdownMenuLabel>
204
- <DropdownMenuGroup>
205
- {overviews.map(({ project }) => (
206
- <DropdownMenuItem key={project.id} onSelect={() => choose(project.id)}>
207
- <FolderIcon />
208
- <span className="flex min-w-0 flex-1 flex-col">
209
- <span className="truncate">{project.name}</span>
210
- <span className="truncate text-xs text-muted-foreground">
211
- {project.path}
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
- </span>
214
- {project.id === selected?.id && <CheckIcon />}
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
- </DropdownMenuGroup>
218
- <DropdownMenuSeparator />
219
- <DropdownMenuGroup>
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 min-w-0 max-w-[45%] justify-end">
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
+ }