kcg-code 0.0.0-stage → 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 +230 -2
- package/bin/kcgcode.ts +13 -0
- package/bun-env.d.ts +19 -0
- package/kcg-code.config.example.json +3 -0
- package/package.json +84 -4
- package/public/logo.svg +1 -0
- package/public/manifest.json +18 -0
- package/public/sw.js +49 -0
- package/src/App.tsx +39 -0
- package/src/__tests__/manifest.test.ts +61 -0
- package/src/build-config.ts +9 -0
- package/src/cli/__tests__/daemon.test.ts +84 -0
- package/src/cli/__tests__/dashboard.test.ts +224 -0
- package/src/cli/__tests__/keep-awake.test.ts +44 -0
- package/src/cli/__tests__/port.test.ts +86 -0
- package/src/cli/args.ts +186 -0
- package/src/cli/attach.ts +154 -0
- package/src/cli/commands.ts +327 -0
- package/src/cli/daemon.ts +275 -0
- package/src/cli/dashboard.tsx +1060 -0
- package/src/cli/index.ts +47 -0
- package/src/cli/keep-awake.ts +148 -0
- package/src/cli/port.ts +92 -0
- package/src/cli/theme.ts +100 -0
- package/src/components/auth/AuthGate.tsx +182 -0
- package/src/components/auth/AvatarEditor.tsx +236 -0
- package/src/components/auth/LockScreen.tsx +714 -0
- package/src/components/auth/ProfileAvatar.tsx +60 -0
- package/src/components/auth/WelcomeScreen.tsx +672 -0
- package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
- package/src/components/auth/avatar-icons.tsx +114 -0
- package/src/components/layout/AppShell.tsx +147 -0
- package/src/components/layout/AppSidebar.tsx +419 -0
- package/src/components/layout/SearchDialog.tsx +189 -0
- package/src/components/layout/SidebarAccount.tsx +111 -0
- package/src/components/layout/SidebarSessionMenu.tsx +163 -0
- package/src/components/layout/ThemeToggle.tsx +67 -0
- package/src/components/projects/FolderBrowser.tsx +142 -0
- package/src/components/projects/HomeChat.tsx +287 -0
- package/src/components/projects/HomeRecentSessions.tsx +90 -0
- package/src/components/projects/NewProjectDialog.tsx +249 -0
- package/src/components/projects/ProjectInstructions.tsx +218 -0
- package/src/components/projects/ProjectRow.tsx +58 -0
- package/src/components/projects/ProjectSidePanel.tsx +514 -0
- package/src/components/sessions/AgentLogo.tsx +42 -0
- package/src/components/sessions/AgentModeList.tsx +265 -0
- package/src/components/sessions/AgentPicker.tsx +132 -0
- package/src/components/sessions/AssistantTurn.tsx +145 -0
- package/src/components/sessions/AttachmentTile.tsx +161 -0
- package/src/components/sessions/CodeBlock.tsx +107 -0
- package/src/components/sessions/ComposerActionSheet.tsx +96 -0
- package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
- package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +92 -0
- package/src/components/sessions/ContextPanel.tsx +661 -0
- package/src/components/sessions/DropPlate.tsx +165 -0
- package/src/components/sessions/FileDropOverlay.tsx +232 -0
- package/src/components/sessions/FileMentionDropdown.tsx +47 -0
- package/src/components/sessions/ImageLightbox.tsx +173 -0
- package/src/components/sessions/MarkdownContent.tsx +187 -0
- package/src/components/sessions/Mascot.tsx +47 -0
- package/src/components/sessions/MessageActions.tsx +60 -0
- package/src/components/sessions/MessageRail.tsx +121 -0
- package/src/components/sessions/ModelPicker.tsx +247 -0
- package/src/components/sessions/ModelSearchList.tsx +231 -0
- package/src/components/sessions/NewSessionDialog.tsx +172 -0
- package/src/components/sessions/PendingAttachments.tsx +127 -0
- package/src/components/sessions/PromptCard.tsx +1085 -0
- package/src/components/sessions/PromptPanel.tsx +112 -0
- package/src/components/sessions/SessionComposer.tsx +641 -0
- package/src/components/sessions/SessionHeader.tsx +176 -0
- package/src/components/sessions/SessionList.tsx +503 -0
- package/src/components/sessions/SessionRow.tsx +237 -0
- package/src/components/sessions/SessionTimeline.tsx +275 -0
- package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
- package/src/components/sessions/SessionView.tsx +240 -0
- package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
- package/src/components/sessions/UserMessage.tsx +177 -0
- package/src/components/sessions/__tests__/ContextPanel.test.tsx +256 -0
- package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
- package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
- package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
- package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
- package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
- package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
- package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
- package/src/components/settings/AboutGroup.tsx +170 -0
- package/src/components/settings/AccountGroup.tsx +122 -0
- package/src/components/settings/DevicesGroup.tsx +151 -0
- package/src/components/settings/LhrRemoteAccess.tsx +257 -0
- package/src/components/settings/PasskeysGroup.tsx +551 -0
- package/src/components/settings/RemoteAccessGroup.tsx +1201 -0
- package/src/components/settings/SecurityGroup.tsx +566 -0
- package/src/components/settings/WorkspaceGroup.tsx +370 -0
- package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
- package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
- package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
- package/src/components/settings/prefs.tsx +333 -0
- package/src/components/skills/InstallNotifications.tsx +326 -0
- package/src/components/skills/InstallSkillDialog.tsx +390 -0
- package/src/components/skills/SkillCards.tsx +254 -0
- package/src/components/skills/SkillsProjectPicker.tsx +178 -0
- package/src/components/ui/adw-dialog.tsx +114 -0
- package/src/components/ui/adw-toggle-group.tsx +75 -0
- package/src/components/ui/alert-dialog.tsx +179 -0
- package/src/components/ui/alert.tsx +60 -0
- package/src/components/ui/badge.tsx +46 -0
- package/src/components/ui/bubble.tsx +125 -0
- package/src/components/ui/button.tsx +62 -0
- package/src/components/ui/card.tsx +75 -0
- package/src/components/ui/collapsible.tsx +19 -0
- package/src/components/ui/dialog.tsx +142 -0
- package/src/components/ui/dropdown-menu.tsx +225 -0
- package/src/components/ui/empty.tsx +94 -0
- package/src/components/ui/field.tsx +234 -0
- package/src/components/ui/input-group.tsx +161 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/message-scroller.tsx +131 -0
- package/src/components/ui/message.tsx +85 -0
- package/src/components/ui/popover.tsx +46 -0
- package/src/components/ui/search-entry.tsx +70 -0
- package/src/components/ui/select.tsx +172 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +138 -0
- package/src/components/ui/sidebar.tsx +699 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/sonner.tsx +45 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/switch.tsx +32 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/tooltip.tsx +51 -0
- package/src/config.ts +114 -0
- package/src/db.ts +146 -0
- package/src/frontend.tsx +51 -0
- package/src/hooks/__tests__/useMention.test.ts +79 -0
- package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
- package/src/hooks/__tests__/useRouter.test.ts +17 -0
- package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
- package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
- package/src/hooks/__tests__/useTheme.test.ts +118 -0
- package/src/hooks/use-mobile.ts +16 -0
- package/src/hooks/useAgentPicker.ts +101 -0
- package/src/hooks/useMediaQuery.ts +49 -0
- package/src/hooks/useMention.ts +236 -0
- package/src/hooks/useProjects.ts +88 -0
- package/src/hooks/useResyncOnReconnect.ts +28 -0
- package/src/hooks/useRouter.ts +92 -0
- package/src/hooks/useSessionChat.ts +427 -0
- package/src/hooks/useSessionContext.ts +106 -0
- package/src/hooks/useSessionList.ts +327 -0
- package/src/hooks/useSidebarData.ts +108 -0
- package/src/hooks/useSmoothText.ts +122 -0
- package/src/hooks/useTheme.ts +164 -0
- package/src/hooks/useWebSocket.ts +196 -0
- package/src/index.css +27 -0
- package/src/index.html +14 -0
- package/src/index.ts +49 -0
- package/src/lib/__tests__/attachments.test.ts +58 -0
- package/src/lib/__tests__/avatar-presets.test.ts +25 -0
- package/src/lib/__tests__/collapsible.test.ts +101 -0
- package/src/lib/__tests__/composer.test.ts +118 -0
- package/src/lib/__tests__/highlight.test.ts +42 -0
- package/src/lib/__tests__/home-chat.test.ts +64 -0
- package/src/lib/__tests__/lock-screen.test.ts +64 -0
- package/src/lib/__tests__/model-picker.test.ts +205 -0
- package/src/lib/__tests__/onboarding.test.ts +41 -0
- package/src/lib/__tests__/permission-info.test.ts +61 -0
- package/src/lib/__tests__/project-extensions.test.ts +83 -0
- package/src/lib/__tests__/project-overview.test.ts +188 -0
- package/src/lib/__tests__/references.test.ts +121 -0
- package/src/lib/__tests__/routes.test.ts +134 -0
- package/src/lib/__tests__/session-selection.test.ts +41 -0
- package/src/lib/__tests__/session-summary.test.ts +115 -0
- package/src/lib/__tests__/session-title.test.ts +58 -0
- package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
- package/src/lib/__tests__/skill-installs.test.ts +135 -0
- package/src/lib/__tests__/skills-catalog.test.ts +124 -0
- package/src/lib/__tests__/tone.test.ts +37 -0
- package/src/lib/__tests__/turns-delta.test.ts +55 -0
- package/src/lib/agent-types.ts +49 -0
- package/src/lib/api.ts +306 -0
- package/src/lib/attachments.ts +203 -0
- package/src/lib/auth.ts +92 -0
- package/src/lib/avatar-presets.ts +31 -0
- package/src/lib/collapsible.ts +75 -0
- package/src/lib/composer.ts +89 -0
- package/src/lib/data-events.ts +17 -0
- package/src/lib/headlines.ts +30 -0
- package/src/lib/highlight.ts +186 -0
- package/src/lib/layout.ts +20 -0
- package/src/lib/lock-screen.ts +54 -0
- package/src/lib/model-picker.ts +133 -0
- package/src/lib/onboarding.ts +38 -0
- package/src/lib/passkeys.ts +113 -0
- package/src/lib/pending-prompt.ts +53 -0
- package/src/lib/permission-info.ts +101 -0
- package/src/lib/project-extensions.ts +136 -0
- package/src/lib/project-overview.ts +123 -0
- package/src/lib/prompts.ts +87 -0
- package/src/lib/references.ts +106 -0
- package/src/lib/routes.ts +124 -0
- package/src/lib/session-handoff.ts +25 -0
- package/src/lib/session-selection.ts +40 -0
- package/src/lib/session-status.ts +45 -0
- package/src/lib/session-summary.ts +68 -0
- package/src/lib/session-title.ts +63 -0
- package/src/lib/sidebar-groups.ts +78 -0
- package/src/lib/skill-installs.ts +362 -0
- package/src/lib/skills-catalog.ts +138 -0
- package/src/lib/time.ts +22 -0
- package/src/lib/tone.ts +80 -0
- package/src/lib/turns.ts +320 -0
- package/src/lib/utils.ts +6 -0
- package/src/logo.svg +31 -0
- package/src/pages/projects/ProjectDetailPage.tsx +131 -0
- package/src/pages/projects/ProjectsPage.tsx +97 -0
- package/src/pages/sessions/SessionPage.tsx +122 -0
- package/src/pages/settings/SettingsPage.tsx +366 -0
- package/src/pages/settings/__tests__/SettingsPage.test.ts +16 -0
- package/src/pages/skills/SkillsPage.tsx +745 -0
- package/src/paths.ts +121 -0
- package/src/runtime.ts +24 -0
- package/src/server/__tests__/app.test.ts +1044 -0
- package/src/server/__tests__/auth.test.ts +370 -0
- package/src/server/__tests__/config.test.ts +101 -0
- package/src/server/__tests__/db.test.ts +485 -0
- package/src/server/__tests__/host.test.ts +14 -0
- package/src/server/__tests__/tunnel-config.test.ts +38 -0
- package/src/server/app.ts +427 -0
- package/src/server/db/auth.ts +311 -0
- package/src/server/db/messages.ts +50 -0
- package/src/server/db/projects.ts +106 -0
- package/src/server/db/prompts.ts +78 -0
- package/src/server/db/rows.ts +150 -0
- package/src/server/db/schema.ts +221 -0
- package/src/server/db/sessions.ts +239 -0
- package/src/server/db/tunnel.ts +124 -0
- package/src/server/host.ts +12 -0
- package/src/server/result.ts +15 -0
- package/src/server/routes/auth-guard.ts +156 -0
- package/src/server/routes/auth.routes.ts +287 -0
- package/src/server/routes/helpers.ts +165 -0
- package/src/server/routes/meta.routes.ts +24 -0
- package/src/server/routes/passkeys.routes.ts +161 -0
- package/src/server/routes/projects.routes.ts +167 -0
- package/src/server/routes/sessions.routes.ts +197 -0
- package/src/server/routes/skills.routes.ts +136 -0
- package/src/server/routes/tunnel.routes.ts +132 -0
- package/src/server/routes/types.ts +35 -0
- package/src/server/routes/uploads.routes.ts +54 -0
- package/src/server/routes/workspace.routes.ts +127 -0
- package/src/server/services/__tests__/app-info.test.ts +18 -0
- package/src/server/services/__tests__/attachments.test.ts +159 -0
- package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
- package/src/server/services/__tests__/auth.test.ts +341 -0
- 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__/frpc-installer.test.ts +83 -0
- package/src/server/services/__tests__/git-info.test.ts +74 -0
- package/src/server/services/__tests__/opencode.test.ts +320 -0
- package/src/server/services/__tests__/project-manager.test.ts +190 -0
- package/src/server/services/__tests__/sandbox.test.ts +141 -0
- package/src/server/services/__tests__/session-manager.test.ts +3509 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +245 -0
- package/src/server/services/__tests__/skills-registry.test.ts +365 -0
- package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
- package/src/server/services/__tests__/tunnel.test.ts +367 -0
- package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
- package/src/server/services/__tests__/workspace.test.ts +249 -0
- package/src/server/services/app-info.ts +29 -0
- package/src/server/services/attachments.ts +193 -0
- package/src/server/services/auth-passkeys.ts +459 -0
- package/src/server/services/auth.ts +633 -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/frpc-installer.ts +183 -0
- package/src/server/services/git-info.ts +87 -0
- package/src/server/services/opencode-client.ts +714 -0
- package/src/server/services/opencode-server.ts +319 -0
- package/src/server/services/opencode-sse.ts +82 -0
- package/src/server/services/project-manager.ts +147 -0
- package/src/server/services/sandbox.ts +83 -0
- package/src/server/services/session-events.ts +256 -0
- package/src/server/services/session-manager.ts +1439 -0
- package/src/server/services/skill-install-jobs.ts +275 -0
- package/src/server/services/skills-registry.ts +480 -0
- package/src/server/services/tunnel-api.ts +207 -0
- package/src/server/services/tunnel-config.ts +45 -0
- package/src/server/services/tunnel-lhr.ts +387 -0
- package/src/server/services/tunnel.ts +633 -0
- package/src/server/services/turn-stream.ts +339 -0
- package/src/server/services/websocket-gateway.ts +374 -0
- package/src/server/services/workspace.ts +223 -0
- package/src/server/services/ws-transport.ts +121 -0
- package/src/types/context.ts +69 -0
- package/src/types/index.ts +13 -0
- package/src/types/message.ts +58 -0
- package/src/types/project.ts +16 -0
- package/src/types/prompt.ts +118 -0
- package/src/types/session.ts +52 -0
- package/src/types/turns.ts +47 -0
- package/src/ws-protocol.ts +93 -0
- package/styles/globals.css +311 -0
- package/tsconfig.json +37 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daftar model dengan pencarian — komponen presentasional bersama.
|
|
3
|
+
*
|
|
4
|
+
* Dipakai dua tempat dengan cara berbeda:
|
|
5
|
+
* - `ModelPicker.tsx` (ganti model Session berjalan) membukanya sebagai dialog.
|
|
6
|
+
* - `NewSessionDialog.tsx` (buat Session baru) menyematkannya di dalam form.
|
|
7
|
+
*
|
|
8
|
+
* Gaya libadwaita (GNOME): kolom cari ala GtkSearchEntry (terisi, tanpa
|
|
9
|
+
* border, tombol hapus), lalu "boxed list" per provider seperti
|
|
10
|
+
* AdwPreferencesGroup — judul grup di atas kartu membulat berbaris
|
|
11
|
+
* pemisah; model aktif ditandai centang di kanan (bukan latar), hasil kosong
|
|
12
|
+
* memakai pola AdwStatusPage. Enter di kolom cari memilih hasil pertama.
|
|
13
|
+
*
|
|
14
|
+
* Komponen ini tidak melakukan fetch maupun simpan: pemanggil menyediakan
|
|
15
|
+
* `models` dan menerima pilihan lewat `onSelect`. Aturan pencarian dan
|
|
16
|
+
* pengelompokan berasal dari `lib/model-picker.ts` (murni, teruji).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { CheckIcon, SearchIcon } from "lucide-react";
|
|
20
|
+
import { type ReactNode, useMemo, useState } from "react";
|
|
21
|
+
import { SearchEntry } from "@/components/ui/search-entry";
|
|
22
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
23
|
+
import {
|
|
24
|
+
DEFAULT_MODEL_KEY,
|
|
25
|
+
describeModelCount,
|
|
26
|
+
filterModels,
|
|
27
|
+
groupModels,
|
|
28
|
+
optionKey,
|
|
29
|
+
} from "@/lib/model-picker";
|
|
30
|
+
import { cn } from "@/lib/utils";
|
|
31
|
+
import type { ModelOption } from "@/server/services/opencode-client";
|
|
32
|
+
import type { SessionModel } from "@/types";
|
|
33
|
+
|
|
34
|
+
export interface ModelSearchListProps {
|
|
35
|
+
/** Daftar model dari server; `null` = belum dimuat. */
|
|
36
|
+
models: ModelOption[] | null;
|
|
37
|
+
loading: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Kunci model aktif/terpilih (lihat `keyOfSessionModel`) — ditandai
|
|
40
|
+
* centang. Tanpa nilai: tidak ada centang (mode "klik untuk mulai").
|
|
41
|
+
*/
|
|
42
|
+
activeKey?: string;
|
|
43
|
+
/** Baris yang pilihannya sedang diproses — spinner menggantikan centang. */
|
|
44
|
+
pendingKey?: string | null;
|
|
45
|
+
/** Pilihan baru; `null` = pakai model default opencode. */
|
|
46
|
+
onSelect: (model: SessionModel | null) => void;
|
|
47
|
+
/** Kunci aksi sementara pilihan sedang disimpan. */
|
|
48
|
+
disabled?: boolean;
|
|
49
|
+
/** Tinggi area daftar (kelas Tailwind); dialog memakai flex-1. */
|
|
50
|
+
className?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function ModelSearchList({
|
|
54
|
+
models,
|
|
55
|
+
loading,
|
|
56
|
+
activeKey,
|
|
57
|
+
pendingKey = null,
|
|
58
|
+
onSelect,
|
|
59
|
+
disabled = false,
|
|
60
|
+
className,
|
|
61
|
+
}: ModelSearchListProps) {
|
|
62
|
+
const [query, setQuery] = useState("");
|
|
63
|
+
|
|
64
|
+
const filtered = useMemo(() => filterModels(models ?? [], query), [models, query]);
|
|
65
|
+
const groups = useMemo(() => groupModels(filtered), [filtered]);
|
|
66
|
+
/** Opsi "default" ikut disaring agar hasil pencarian konsisten. */
|
|
67
|
+
const showDefault = useMemo(() => {
|
|
68
|
+
const q = query.trim().toLowerCase();
|
|
69
|
+
return q === "" || "model default".includes(q);
|
|
70
|
+
}, [query]);
|
|
71
|
+
const empty = !loading && !showDefault && groups.length === 0;
|
|
72
|
+
const isClaude = models?.[0]?.providerID === "claude-code";
|
|
73
|
+
|
|
74
|
+
/** Enter di kolom cari: pilih hasil teratas (default bila masih tampil). */
|
|
75
|
+
const pickFirst = () => {
|
|
76
|
+
if (loading || disabled) return;
|
|
77
|
+
if (showDefault) return onSelect(null);
|
|
78
|
+
const first = groups[0]?.models[0];
|
|
79
|
+
if (first) onSelect({ providerID: first.providerID, modelID: first.modelID });
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div className={cn("flex min-h-0 flex-col gap-3", className)}>
|
|
84
|
+
<SearchEntry
|
|
85
|
+
className="shrink-0"
|
|
86
|
+
value={query}
|
|
87
|
+
onValueChange={setQuery}
|
|
88
|
+
onKeyDown={(e) => {
|
|
89
|
+
if (e.key === "Enter") {
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
pickFirst();
|
|
92
|
+
}
|
|
93
|
+
// Esc: SearchEntry mengosongkan cari dulu (dialog pemanggil menahan
|
|
94
|
+
// tutupnya, lihat `ModelPicker`); Esc berikutnya menutup dialog.
|
|
95
|
+
}}
|
|
96
|
+
placeholder={
|
|
97
|
+
models && models.length > 0 ? `Search ${models.length} models…` : "Search models…"
|
|
98
|
+
}
|
|
99
|
+
aria-label="Search models or providers"
|
|
100
|
+
aria-describedby="model-search-count"
|
|
101
|
+
/>
|
|
102
|
+
<span id="model-search-count" className="sr-only" aria-live="polite">
|
|
103
|
+
{loading ? "Loading models" : describeModelCount(filtered.length)}
|
|
104
|
+
</span>
|
|
105
|
+
|
|
106
|
+
{/* `-mx-1 px-1`: ruang untuk ring fokus baris agar tidak terpotong. */}
|
|
107
|
+
<div className="-mx-1 min-h-0 flex-1 overflow-y-auto overscroll-contain px-1 pb-1">
|
|
108
|
+
{loading ? (
|
|
109
|
+
<div className="flex h-full min-h-40 flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
|
|
110
|
+
<Spinner className="size-6" />
|
|
111
|
+
Loading models…
|
|
112
|
+
</div>
|
|
113
|
+
) : empty ? (
|
|
114
|
+
// AdwStatusPage
|
|
115
|
+
<div className="flex h-full min-h-40 flex-col items-center justify-center gap-2 px-6 text-center">
|
|
116
|
+
<SearchIcon className="mb-2 size-12 text-muted-foreground/40" aria-hidden />
|
|
117
|
+
<p className="text-lg font-semibold">No Results Found</p>
|
|
118
|
+
<p className="text-sm text-muted-foreground">
|
|
119
|
+
No model matches “{query.trim()}”. Try a different search.
|
|
120
|
+
</p>
|
|
121
|
+
</div>
|
|
122
|
+
) : (
|
|
123
|
+
<div className="flex flex-col gap-5">
|
|
124
|
+
{showDefault && (
|
|
125
|
+
<BoxedList label="Default">
|
|
126
|
+
<ModelRow
|
|
127
|
+
name="Default model"
|
|
128
|
+
hint={
|
|
129
|
+
isClaude
|
|
130
|
+
? "Follows your Claude Code settings"
|
|
131
|
+
: "Follows opencode configuration"
|
|
132
|
+
}
|
|
133
|
+
selected={activeKey === DEFAULT_MODEL_KEY}
|
|
134
|
+
pending={pendingKey === DEFAULT_MODEL_KEY}
|
|
135
|
+
disabled={disabled}
|
|
136
|
+
onSelect={() => onSelect(null)}
|
|
137
|
+
/>
|
|
138
|
+
</BoxedList>
|
|
139
|
+
)}
|
|
140
|
+
{groups.map((g) => (
|
|
141
|
+
<PrefsGroup key={g.providerID} title={g.providerName}>
|
|
142
|
+
{g.models.map((m) => (
|
|
143
|
+
<ModelRow
|
|
144
|
+
key={optionKey(m)}
|
|
145
|
+
name={m.name}
|
|
146
|
+
hint={m.modelID}
|
|
147
|
+
selected={activeKey === optionKey(m)}
|
|
148
|
+
pending={pendingKey === optionKey(m)}
|
|
149
|
+
disabled={disabled}
|
|
150
|
+
onSelect={() => onSelect({ providerID: m.providerID, modelID: m.modelID })}
|
|
151
|
+
/>
|
|
152
|
+
))}
|
|
153
|
+
</PrefsGroup>
|
|
154
|
+
))}
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Kartu membulat berisi baris dengan garis pemisah (libadwaita `.boxed-list`). */
|
|
163
|
+
function BoxedList({ label, children }: { label: string; children: ReactNode }) {
|
|
164
|
+
return (
|
|
165
|
+
<ul
|
|
166
|
+
aria-label={label}
|
|
167
|
+
className="flex flex-col divide-y overflow-hidden rounded-xl border bg-card shadow-xs"
|
|
168
|
+
>
|
|
169
|
+
{children}
|
|
170
|
+
</ul>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Judul grup + boxed list (AdwPreferencesGroup). */
|
|
175
|
+
function PrefsGroup({ title, children }: { title: string; children: ReactNode }) {
|
|
176
|
+
return (
|
|
177
|
+
<section className="flex flex-col gap-2">
|
|
178
|
+
<h3 className="px-1 text-[13px] font-semibold">{title}</h3>
|
|
179
|
+
<BoxedList label={title}>{children}</BoxedList>
|
|
180
|
+
</section>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
interface ModelRowProps {
|
|
185
|
+
name: string;
|
|
186
|
+
hint: string;
|
|
187
|
+
selected: boolean;
|
|
188
|
+
/** Pilihan ini sedang diproses: spinner, tidak ikut diredupkan. */
|
|
189
|
+
pending: boolean;
|
|
190
|
+
disabled: boolean;
|
|
191
|
+
onSelect: () => void;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Satu baris model (AdwActionRow yang bisa diaktifkan): judul + subjudul,
|
|
196
|
+
* centang aksen di kanan untuk pilihan aktif. `min-h-13` = target sentuh
|
|
197
|
+
* nyaman di HP.
|
|
198
|
+
*/
|
|
199
|
+
function ModelRow({ name, hint, selected, pending, disabled, onSelect }: ModelRowProps) {
|
|
200
|
+
return (
|
|
201
|
+
<li>
|
|
202
|
+
<button
|
|
203
|
+
type="button"
|
|
204
|
+
onClick={onSelect}
|
|
205
|
+
disabled={disabled}
|
|
206
|
+
aria-current={selected}
|
|
207
|
+
aria-busy={pending || undefined}
|
|
208
|
+
className={cn(
|
|
209
|
+
"flex min-h-13 w-full items-center gap-3 px-4 py-2 text-left transition-colors outline-none hover:bg-muted/60 focus-visible:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset active:bg-muted disabled:pointer-events-none",
|
|
210
|
+
pending ? "bg-muted/60" : "disabled:opacity-50",
|
|
211
|
+
)}
|
|
212
|
+
>
|
|
213
|
+
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
214
|
+
<span className="truncate text-[15px] leading-5">{name}</span>
|
|
215
|
+
<span className="truncate text-[13px] leading-snug text-muted-foreground">{hint}</span>
|
|
216
|
+
</span>
|
|
217
|
+
{pending ? (
|
|
218
|
+
<Spinner className="size-[18px] shrink-0 text-muted-foreground" />
|
|
219
|
+
) : (
|
|
220
|
+
selected && (
|
|
221
|
+
<CheckIcon
|
|
222
|
+
className="size-[18px] shrink-0 text-primary"
|
|
223
|
+
strokeWidth={2.5}
|
|
224
|
+
aria-hidden
|
|
225
|
+
/>
|
|
226
|
+
)
|
|
227
|
+
)}
|
|
228
|
+
</button>
|
|
229
|
+
</li>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dialog pembuatan Session baru (halaman detail Project) — gaya AdwDialog
|
|
3
|
+
* (libadwaita), sama dengan dialog Agent & Model:
|
|
4
|
+
* - Header bar: judul "New Session" + nama Project, tombol tutup bundar.
|
|
5
|
+
* - Isi: AdwToggleGroup agent (OpenCode / Claude Code), lalu daftar model
|
|
6
|
+
* (`GET /api/projects/:id/models?agent=`, dimuat saat dialog dibuka).
|
|
7
|
+
* - Klik model (atau Enter di kolom cari = hasil teratas) LANGSUNG membuat
|
|
8
|
+
* Session dengan agent & model itu — tanpa tombol Create. Selama dibuat,
|
|
9
|
+
* baris yang diklik menampilkan spinner dan daftar dikunci.
|
|
10
|
+
* - Di HP tampil sebagai bottom sheet (`AdwDialogContent`).
|
|
11
|
+
*
|
|
12
|
+
* Agent awal = agent terakhir yang dipakai (sama dengan composer homepage).
|
|
13
|
+
* `POST /api/sessions` — direktori kerja memakai path Project
|
|
14
|
+
* (Requirement 10.9), tipe tidak didukung ditolak (1.3). Setelah berhasil,
|
|
15
|
+
* pemanggil menerima Session baru (mis. untuk langsung membukanya).
|
|
16
|
+
*/
|
|
17
|
+
import { useEffect, useState } from "react";
|
|
18
|
+
import { toast } from "sonner";
|
|
19
|
+
import { AgentLogo } from "@/components/sessions/AgentLogo";
|
|
20
|
+
import { AdwDialogContent } from "@/components/ui/adw-dialog";
|
|
21
|
+
import { AdwToggleGroup } from "@/components/ui/adw-toggle-group";
|
|
22
|
+
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
23
|
+
import { Dialog } from "@/components/ui/dialog";
|
|
24
|
+
import {
|
|
25
|
+
AGENT_TYPE_LABELS,
|
|
26
|
+
AGENT_TYPES,
|
|
27
|
+
agentQuery,
|
|
28
|
+
readPreferredAgentType,
|
|
29
|
+
storePreferredAgentType,
|
|
30
|
+
} from "@/lib/agent-types";
|
|
31
|
+
import { ApiError, apiFetch } from "@/lib/api";
|
|
32
|
+
import { keyOfSessionModel } from "@/lib/model-picker";
|
|
33
|
+
import type { ModelOption } from "@/server/services/opencode-client";
|
|
34
|
+
import type { AgentType, Session, SessionModel } from "@/types";
|
|
35
|
+
import { ModelSearchList } from "./ModelSearchList";
|
|
36
|
+
|
|
37
|
+
export interface NewSessionDialogProps {
|
|
38
|
+
open: boolean;
|
|
39
|
+
onOpenChange: (open: boolean) => void;
|
|
40
|
+
projectId: string;
|
|
41
|
+
/** Subjudul header bar. */
|
|
42
|
+
projectName: string;
|
|
43
|
+
/** Dipanggil dengan Session yang baru dibuat. */
|
|
44
|
+
onCreated: (session: Session) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function NewSessionDialog({
|
|
48
|
+
open,
|
|
49
|
+
onOpenChange,
|
|
50
|
+
projectId,
|
|
51
|
+
projectName,
|
|
52
|
+
onCreated,
|
|
53
|
+
}: NewSessionDialogProps) {
|
|
54
|
+
const [agentType, setAgentType] = useState<AgentType>(readPreferredAgentType);
|
|
55
|
+
/** Kunci model yang sedang dipakai membuat Session (spinner di barisnya). */
|
|
56
|
+
const [pendingKey, setPendingKey] = useState<string | null>(null);
|
|
57
|
+
const [models, setModels] = useState<ModelOption[] | null>(null);
|
|
58
|
+
const [modelsLoading, setModelsLoading] = useState(false);
|
|
59
|
+
const [modelsError, setModelsError] = useState<string | null>(null);
|
|
60
|
+
const [creating, setCreating] = useState(false);
|
|
61
|
+
const [createError, setCreateError] = useState<string | null>(null);
|
|
62
|
+
|
|
63
|
+
// Dibuka lagi -> mulai dari agent terakhir dipakai.
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!open) return;
|
|
66
|
+
setAgentType(readPreferredAgentType());
|
|
67
|
+
setCreateError(null);
|
|
68
|
+
}, [open]);
|
|
69
|
+
|
|
70
|
+
// Daftar model selalu di-fetch ulang saat dialog dibuka / agent berganti
|
|
71
|
+
// agar perubahan config terlihat tanpa reload halaman. Respons usang
|
|
72
|
+
// (agent sudah diganti lagi) dibuang.
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!open) return;
|
|
75
|
+
let stale = false;
|
|
76
|
+
setModelsLoading(true);
|
|
77
|
+
setModelsError(null);
|
|
78
|
+
apiFetch(`/api/projects/${projectId}/models${agentQuery(agentType)}`)
|
|
79
|
+
.then(async (res) => {
|
|
80
|
+
const body = (await res.json()) as { models: ModelOption[] };
|
|
81
|
+
if (!stale) setModels(body.models);
|
|
82
|
+
})
|
|
83
|
+
.catch((e: unknown) => {
|
|
84
|
+
if (stale) return;
|
|
85
|
+
setModelsError(e instanceof ApiError ? e.message : "Failed to load models");
|
|
86
|
+
setModels([]);
|
|
87
|
+
})
|
|
88
|
+
.finally(() => {
|
|
89
|
+
if (!stale) setModelsLoading(false);
|
|
90
|
+
});
|
|
91
|
+
return () => {
|
|
92
|
+
stale = true;
|
|
93
|
+
};
|
|
94
|
+
}, [open, projectId, agentType]);
|
|
95
|
+
|
|
96
|
+
/** Klik model = buat Session dengan agent & model ini, lalu buka. */
|
|
97
|
+
const create = async (model: SessionModel | null) => {
|
|
98
|
+
if (creating) return;
|
|
99
|
+
setCreating(true);
|
|
100
|
+
setPendingKey(keyOfSessionModel(model));
|
|
101
|
+
setCreateError(null);
|
|
102
|
+
try {
|
|
103
|
+
const res = await apiFetch("/api/sessions", {
|
|
104
|
+
method: "POST",
|
|
105
|
+
body: JSON.stringify({ agentType, projectId, model }),
|
|
106
|
+
});
|
|
107
|
+
const { session } = (await res.json()) as { session: Session };
|
|
108
|
+
storePreferredAgentType(agentType);
|
|
109
|
+
onOpenChange(false);
|
|
110
|
+
onCreated(session);
|
|
111
|
+
toast.success("Session created.");
|
|
112
|
+
} catch (e) {
|
|
113
|
+
setCreateError(e instanceof ApiError ? e.message : "Failed to create session");
|
|
114
|
+
} finally {
|
|
115
|
+
setCreating(false);
|
|
116
|
+
setPendingKey(null);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<Dialog
|
|
122
|
+
open={open}
|
|
123
|
+
onOpenChange={(next) => {
|
|
124
|
+
if (creating) return;
|
|
125
|
+
onOpenChange(next);
|
|
126
|
+
}}
|
|
127
|
+
>
|
|
128
|
+
<AdwDialogContent
|
|
129
|
+
title="New Session"
|
|
130
|
+
subtitle={creating ? "Starting…" : projectName}
|
|
131
|
+
closeDisabled={creating}
|
|
132
|
+
>
|
|
133
|
+
<AdwToggleGroup
|
|
134
|
+
label="Agent"
|
|
135
|
+
value={agentType}
|
|
136
|
+
disabled={creating}
|
|
137
|
+
onChange={setAgentType}
|
|
138
|
+
options={AGENT_TYPES.map((t) => ({
|
|
139
|
+
value: t,
|
|
140
|
+
label: AGENT_TYPE_LABELS[t],
|
|
141
|
+
icon: <AgentLogo agentType={t} />,
|
|
142
|
+
}))}
|
|
143
|
+
/>
|
|
144
|
+
|
|
145
|
+
{createError && (
|
|
146
|
+
<Alert variant="destructive">
|
|
147
|
+
<AlertTitle>Failed to create session</AlertTitle>
|
|
148
|
+
<AlertDescription>{createError}</AlertDescription>
|
|
149
|
+
</Alert>
|
|
150
|
+
)}
|
|
151
|
+
|
|
152
|
+
{modelsError ? (
|
|
153
|
+
<Alert variant="destructive">
|
|
154
|
+
<AlertTitle>Failed to load models</AlertTitle>
|
|
155
|
+
<AlertDescription>{modelsError}</AlertDescription>
|
|
156
|
+
</Alert>
|
|
157
|
+
) : (
|
|
158
|
+
// Tanpa `activeKey`: tidak ada centang — tiap baris adalah aksi
|
|
159
|
+
// "mulai dengan model ini".
|
|
160
|
+
<ModelSearchList
|
|
161
|
+
models={models}
|
|
162
|
+
loading={modelsLoading}
|
|
163
|
+
pendingKey={pendingKey}
|
|
164
|
+
onSelect={(model) => void create(model)}
|
|
165
|
+
disabled={creating}
|
|
166
|
+
className="flex-1"
|
|
167
|
+
/>
|
|
168
|
+
)}
|
|
169
|
+
</AdwDialogContent>
|
|
170
|
+
</Dialog>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lampiran yang akan dikirim bersama pesan berikutnya, tampil DI DALAM kotak
|
|
3
|
+
* composer (di atas textarea) — jelas bahwa file ikut pesan ini.
|
|
4
|
+
*
|
|
5
|
+
* - Gambar: thumbnail kotak; klik = pratinjau layar penuh (`ImageLightbox`).
|
|
6
|
+
* - File lain: kartu ikon + nama + jenis · ukuran.
|
|
7
|
+
* - Tombol hapus selalu terlihat (layar sentuh tidak punya hover).
|
|
8
|
+
* - Baris bisa di-scroll horizontal bila lampiran banyak.
|
|
9
|
+
* - `hiddenKeys`: item yang sedang "mendarat" dari animasi plate drop —
|
|
10
|
+
* tetap menempati tempatnya (diukur sebagai tujuan animasi) tapi belum
|
|
11
|
+
* terlihat; `registerEl` memberi elemen tiap item ke pemanggil.
|
|
12
|
+
*
|
|
13
|
+
* Object URL dibuat & dilepas oleh pemilik state (`SessionComposer`).
|
|
14
|
+
*/
|
|
15
|
+
import { XIcon } from "lucide-react";
|
|
16
|
+
import { AnimatePresence } from "motion/react";
|
|
17
|
+
import * as m from "motion/react-m";
|
|
18
|
+
import { useState } from "react";
|
|
19
|
+
import { FileTile } from "@/components/sessions/AttachmentTile";
|
|
20
|
+
import { ImageLightbox } from "@/components/sessions/ImageLightbox";
|
|
21
|
+
import { cn } from "@/lib/utils";
|
|
22
|
+
|
|
23
|
+
export interface PendingAttachment {
|
|
24
|
+
key: string;
|
|
25
|
+
file: File;
|
|
26
|
+
/** Object URL pratinjau — hanya untuk gambar yang bisa dipratinjau. */
|
|
27
|
+
previewUrl: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Ukuran kotak (dipakai juga oleh plate agar mendarat pas di tempatnya). */
|
|
31
|
+
export const IMAGE_TILE_CLASS = "size-16 sm:size-[4.5rem]";
|
|
32
|
+
export const FILE_TILE_CLASS = "h-16 w-48 sm:h-[4.5rem]";
|
|
33
|
+
|
|
34
|
+
/** Isi visual satu lampiran tanpa tombol (dipakai list & plate). */
|
|
35
|
+
export function AttachmentVisual({ item }: { item: PendingAttachment }) {
|
|
36
|
+
if (item.previewUrl) {
|
|
37
|
+
return (
|
|
38
|
+
<span className={cn("block overflow-hidden rounded-lg border bg-muted", IMAGE_TILE_CLASS)}>
|
|
39
|
+
<img src={item.previewUrl} alt="" className="size-full object-cover" draggable={false} />
|
|
40
|
+
</span>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return (
|
|
44
|
+
<FileTile
|
|
45
|
+
filename={item.file.name}
|
|
46
|
+
mime={item.file.type}
|
|
47
|
+
size={item.file.size}
|
|
48
|
+
className={FILE_TILE_CLASS}
|
|
49
|
+
/>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface PendingAttachmentsProps {
|
|
54
|
+
items: PendingAttachment[];
|
|
55
|
+
onRemove: (key: string) => void;
|
|
56
|
+
hiddenKeys?: ReadonlySet<string>;
|
|
57
|
+
registerEl?: (key: string, el: HTMLElement | null) => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function PendingAttachments({
|
|
61
|
+
items,
|
|
62
|
+
onRemove,
|
|
63
|
+
hiddenKeys,
|
|
64
|
+
registerEl,
|
|
65
|
+
}: PendingAttachmentsProps) {
|
|
66
|
+
const [preview, setPreview] = useState<number | null>(null);
|
|
67
|
+
const images = items.filter((it) => it.previewUrl !== null);
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<>
|
|
71
|
+
<ul
|
|
72
|
+
aria-label={`${items.length} ${items.length === 1 ? "attachment" : "attachments"}`}
|
|
73
|
+
className="flex gap-2 overflow-x-auto px-1 pt-1 pb-2 [scrollbar-width:thin]"
|
|
74
|
+
>
|
|
75
|
+
<AnimatePresence initial={false}>
|
|
76
|
+
{items.map((it) => {
|
|
77
|
+
const hidden = hiddenKeys?.has(it.key) ?? false;
|
|
78
|
+
const imageIndex = it.previewUrl ? images.indexOf(it) : -1;
|
|
79
|
+
return (
|
|
80
|
+
<m.li
|
|
81
|
+
key={it.key}
|
|
82
|
+
ref={(el) => registerEl?.(it.key, el)}
|
|
83
|
+
// Item hasil drop sudah "dianimasikan" plate — muncul tanpa
|
|
84
|
+
// animasi masuk agar tidak ada kedipan saat plate berganti.
|
|
85
|
+
initial={hidden ? false : { opacity: 0, scale: 0.85 }}
|
|
86
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
87
|
+
exit={{ opacity: 0, scale: 0.85, transition: { duration: 0.15 } }}
|
|
88
|
+
transition={{ type: "spring", stiffness: 500, damping: 32 }}
|
|
89
|
+
className={cn("relative shrink-0", hidden && "invisible")}
|
|
90
|
+
>
|
|
91
|
+
{it.previewUrl ? (
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
onClick={() => setPreview(imageIndex)}
|
|
95
|
+
aria-label={`Preview ${it.file.name}`}
|
|
96
|
+
className="block rounded-lg outline-none transition-opacity hover:opacity-90 focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
|
97
|
+
>
|
|
98
|
+
<AttachmentVisual item={it} />
|
|
99
|
+
</button>
|
|
100
|
+
) : (
|
|
101
|
+
<span title={it.file.name} className="block">
|
|
102
|
+
<AttachmentVisual item={it} />
|
|
103
|
+
</span>
|
|
104
|
+
)}
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
onClick={() => onRemove(it.key)}
|
|
108
|
+
aria-label={`Remove ${it.file.name}`}
|
|
109
|
+
title="Remove"
|
|
110
|
+
className="absolute top-1 right-1 flex size-6 items-center justify-center rounded-full bg-black/60 text-white shadow-sm backdrop-blur-sm transition-colors outline-none hover:bg-black/80 focus-visible:ring-[3px] focus-visible:ring-white/60"
|
|
111
|
+
>
|
|
112
|
+
<XIcon className="size-3.5" aria-hidden />
|
|
113
|
+
</button>
|
|
114
|
+
</m.li>
|
|
115
|
+
);
|
|
116
|
+
})}
|
|
117
|
+
</AnimatePresence>
|
|
118
|
+
</ul>
|
|
119
|
+
|
|
120
|
+
<ImageLightbox
|
|
121
|
+
images={images.map((it) => ({ src: it.previewUrl as string, alt: it.file.name }))}
|
|
122
|
+
index={preview}
|
|
123
|
+
onIndexChange={setPreview}
|
|
124
|
+
/>
|
|
125
|
+
</>
|
|
126
|
+
);
|
|
127
|
+
}
|