@grackle-ai/web-components 0.107.2 → 0.108.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 (34) hide show
  1. package/.rush/temp/c8da732c75a50be68d2a40b1c14aeab2654cc1af.tar.log +240 -0
  2. package/.rush/temp/{3ae72563f781afd72723475938136f113846603e.untar.log → c8da732c75a50be68d2a40b1c14aeab2654cc1af.untar.log} +2 -2
  3. package/.rush/temp/chunked-rush-logs/web-components._phase_build.chunks.jsonl +18 -0
  4. package/.rush/temp/chunked-rush-logs/web-components._phase_test.chunks.jsonl +121 -0
  5. package/.rush/temp/fb761ea36fead333e4d2b59ce5ade8fb1bc8d724.tar.log +12 -0
  6. package/.rush/temp/{bc1d5bf9201ce71abeaeaddd096deb9b0805d703.untar.log → fb761ea36fead333e4d2b59ce5ade8fb1bc8d724.untar.log} +2 -2
  7. package/.rush/temp/operation/_phase_build/all.log +5 -5
  8. package/.rush/temp/operation/_phase_build/log-chunks.jsonl +5 -5
  9. package/.rush/temp/operation/_phase_build/state.json +1 -1
  10. package/.rush/temp/operation/_phase_test/all.log +17 -17
  11. package/.rush/temp/operation/_phase_test/log-chunks.jsonl +17 -17
  12. package/.rush/temp/operation/_phase_test/state.json +1 -1
  13. package/dist/index.js +7038 -6764
  14. package/package.json +2 -2
  15. package/rush-logs/web-components._phase_build.cache.log +1 -1
  16. package/rush-logs/web-components._phase_build.log +18 -0
  17. package/rush-logs/web-components._phase_test.cache.log +1 -1
  18. package/rush-logs/web-components._phase_test.log +121 -0
  19. package/src/components/panels/EnvironmentEditPanel.stories.tsx +1 -0
  20. package/src/components/panels/EnvironmentEditPanel.tsx +65 -8
  21. package/src/components/panels/GitHubAccountsPanel.tsx +223 -0
  22. package/src/components/panels/index.ts +2 -0
  23. package/src/components/settings/SettingsNav.stories.tsx +11 -10
  24. package/src/components/settings/SettingsNav.tsx +2 -1
  25. package/src/context/GrackleContextTypes.ts +3 -0
  26. package/src/hooks/types.ts +36 -3
  27. package/src/index.ts +4 -3
  28. package/src/mocks/MockGrackleProvider.tsx +21 -0
  29. package/src/mocks/mockData.ts +5 -0
  30. package/src/test-utils/storybook-helpers.ts +1 -0
  31. package/src/utils/breadcrumbs.test.ts +1 -0
  32. package/src/utils/dashboard.test.ts +1 -0
  33. package/src/utils/navigation.ts +3 -0
  34. package/temp/build/lint/_eslint-5eVG3S6w.json +810 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grackle-ai/web-components",
3
- "version": "0.107.2",
3
+ "version": "0.108.1",
4
4
  "description": "Presentational React component library for the Grackle web UI",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,7 +40,7 @@
40
40
  "remark-gfm": "^4.0.0",
41
41
  "lucide-react": "~0.474.0",
42
42
  "react-router": "^7.0.0",
43
- "@grackle-ai/common": "0.107.2"
43
+ "@grackle-ai/common": "0.108.1"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@rushstack/heft": "1.2.7",
@@ -1,4 +1,4 @@
1
1
  Build cache hit.
2
- Cache key: bc1d5bf9201ce71abeaeaddd096deb9b0805d703
2
+ Cache key: c8da732c75a50be68d2a40b1c14aeab2654cc1af
3
3
  Clearing cached folders: dist, storybook-static, .rush/temp/operation/_phase_build
4
4
  Successfully restored output from the build cache.
@@ -0,0 +1,18 @@
1
+ Invoking: heft run --only build -- --clean
2
+ ---- build started ----
3
+ [build:typescript] Using TypeScript version 5.7.3
4
+ [build:storybook-build] Building Storybook...
5
+ [build:storybook-build] Storybook build completed.
6
+ [build:vite-build] Starting Vite build...
7
+ [build:lint] Using ESLint version 9.39.4
8
+ vite v6.4.1 building for production...
9
+ transforming...
10
+ ✓ 2574 modules transformed.
11
+ rendering chunks...
12
+ computing gzip size...
13
+ dist/index.css 156.05 kB │ gzip: 20.36 kB
14
+ dist/index.js 1,373.42 kB │ gzip: 351.63 kB
15
+ ✓ built in 6.79s
16
+ [build:vite-build] Vite build completed.
17
+ ---- build finished (97.523s) ----
18
+ -------------------- Finished (97.527s) --------------------
@@ -1,4 +1,4 @@
1
1
  Build cache hit.
2
- Cache key: 3ae72563f781afd72723475938136f113846603e
2
+ Cache key: fb761ea36fead333e4d2b59ce5ade8fb1bc8d724
3
3
  Clearing cached folders: .rush/temp/operation/_phase_test
4
4
  Successfully restored output from the build cache.
@@ -0,0 +1,121 @@
1
+ Invoking: heft run --only test
2
+ The provided list of phases does not contain all phase dependencies. You may need to run the excluded phases manually.
3
+ ---- test started ----
4
+ [test:vitest] Running vitest...
5
+
6
+ RUN v3.2.4 /home/runner/work/grackle/grackle/packages/web-components
7
+
8
+ ✓ src/utils/sessionEvents.test.ts (14 tests) 48ms
9
+ ✓ src/utils/eventContent.test.ts (38 tests) 155ms
10
+ ✓ src/utils/dashboard.test.ts (4 tests) 33ms
11
+ ✓ src/utils/route-config.test.ts (23 tests) 59ms
12
+ ✓ src/utils/scrollUtils.test.ts (11 tests) 37ms
13
+ ✓ src/utils/breadcrumbs.test.ts (18 tests) 58ms
14
+ ✓ src/components/tools/classifyTool.test.ts (6 tests) 12ms
15
+ ✓ src/components/tools/toolCardHelpers.test.ts (10 tests) 16ms
16
+ ✓ src/components/display/extractText.test.tsx (8 tests) 17ms
17
+ ✓ src/components/editable/useEditableField.test.tsx (17 tests) 164ms
18
+ ✓ src/hooks/useEventSelection.test.ts (13 tests) 220ms
19
+ ✓ src/components/notifications/UpdateBanner.test.tsx (4 tests) 136ms
20
+
21
+ Test Files 12 passed (12)
22
+ Tests 166 passed (166)
23
+ Start at 02:28:03
24
+ Duration 14.69s (transform 2.51s, setup 0ms, collect 16.67s, tests 957ms, environment 8.65s, prepare 3.84s)
25
+
26
+ [test:vitest] Vitest completed.
27
+ [test:storybook-test] Starting Storybook static server on port 34673...
28
+ [test:storybook-test] Storybook server ready. Running interaction tests...
29
+ jest-haste-map: duplicate manual mock found: adapter-manager
30
+ The following files share their name; please delete one of them:
31
+ * <rootDir>/packages/server/dist/__mocks__/adapter-manager.js
32
+ * <rootDir>/packages/server/src/__mocks__/adapter-manager.ts
33
+
34
+ jest-haste-map: duplicate manual mock found: auto-reconnect
35
+ The following files share their name; please delete one of them:
36
+ * <rootDir>/packages/server/dist/__mocks__/auto-reconnect.js
37
+ * <rootDir>/packages/server/src/__mocks__/auto-reconnect.ts
38
+
39
+ jest-haste-map: duplicate manual mock found: event-bus
40
+ The following files share their name; please delete one of them:
41
+ * <rootDir>/packages/server/dist/__mocks__/event-bus.js
42
+ * <rootDir>/packages/server/src/__mocks__/event-bus.ts
43
+
44
+ jest-haste-map: duplicate manual mock found: event-processor
45
+ The following files share their name; please delete one of them:
46
+ * <rootDir>/packages/server/dist/__mocks__/event-processor.js
47
+ * <rootDir>/packages/server/src/__mocks__/event-processor.ts
48
+
49
+ jest-haste-map: duplicate manual mock found: github-import
50
+ The following files share their name; please delete one of them:
51
+ * <rootDir>/packages/server/dist/__mocks__/github-import.js
52
+ * <rootDir>/packages/server/src/__mocks__/github-import.ts
53
+
54
+ jest-haste-map: duplicate manual mock found: lifecycle
55
+ The following files share their name; please delete one of them:
56
+ * <rootDir>/packages/server/dist/__mocks__/lifecycle.js
57
+ * <rootDir>/packages/server/src/__mocks__/lifecycle.ts
58
+
59
+ jest-haste-map: duplicate manual mock found: log-writer
60
+ The following files share their name; please delete one of them:
61
+ * <rootDir>/packages/server/dist/__mocks__/log-writer.js
62
+ * <rootDir>/packages/server/src/__mocks__/log-writer.ts
63
+
64
+ jest-haste-map: duplicate manual mock found: logger
65
+ The following files share their name; please delete one of them:
66
+ * <rootDir>/packages/server/dist/__mocks__/logger.js
67
+ * <rootDir>/packages/server/src/__mocks__/logger.ts
68
+
69
+ jest-haste-map: duplicate manual mock found: pipe-delivery
70
+ The following files share their name; please delete one of them:
71
+ * <rootDir>/packages/server/dist/__mocks__/pipe-delivery.js
72
+ * <rootDir>/packages/server/src/__mocks__/pipe-delivery.ts
73
+
74
+ jest-haste-map: duplicate manual mock found: processor-registry
75
+ The following files share their name; please delete one of them:
76
+ * <rootDir>/packages/server/dist/__mocks__/processor-registry.js
77
+ * <rootDir>/packages/server/src/__mocks__/processor-registry.ts
78
+
79
+ jest-haste-map: duplicate manual mock found: reanimate-agent
80
+ The following files share their name; please delete one of them:
81
+ * <rootDir>/packages/server/dist/__mocks__/reanimate-agent.js
82
+ * <rootDir>/packages/server/src/__mocks__/reanimate-agent.ts
83
+
84
+ jest-haste-map: duplicate manual mock found: session-recovery
85
+ The following files share their name; please delete one of them:
86
+ * <rootDir>/packages/server/dist/__mocks__/session-recovery.js
87
+ * <rootDir>/packages/server/src/__mocks__/session-recovery.ts
88
+
89
+ jest-haste-map: duplicate manual mock found: stream-hub
90
+ The following files share their name; please delete one of them:
91
+ * <rootDir>/packages/server/dist/__mocks__/stream-hub.js
92
+ * <rootDir>/packages/server/src/__mocks__/stream-hub.ts
93
+
94
+ jest-haste-map: duplicate manual mock found: stream-registry
95
+ The following files share their name; please delete one of them:
96
+ * <rootDir>/packages/server/dist/__mocks__/stream-registry.js
97
+ * <rootDir>/packages/server/src/__mocks__/stream-registry.ts
98
+
99
+ jest-haste-map: duplicate manual mock found: token-push
100
+ The following files share their name; please delete one of them:
101
+ * <rootDir>/packages/server/dist/__mocks__/token-push.js
102
+ * <rootDir>/packages/server/src/__mocks__/token-push.ts
103
+
104
+ jest-haste-map: duplicate manual mock found: utils/exec
105
+ The following files share their name; please delete one of them:
106
+ * <rootDir>/packages/server/dist/__mocks__/utils/exec.js
107
+ * <rootDir>/packages/server/src/__mocks__/utils/exec.ts
108
+
109
+ jest-haste-map: duplicate manual mock found: utils/format-gh-error
110
+ The following files share their name; please delete one of them:
111
+ * <rootDir>/packages/server/dist/__mocks__/utils/format-gh-error.js
112
+ * <rootDir>/packages/server/src/__mocks__/utils/format-gh-error.ts
113
+
114
+ jest-haste-map: duplicate manual mock found: utils/network
115
+ The following files share their name; please delete one of them:
116
+ * <rootDir>/packages/server/dist/__mocks__/utils/network.js
117
+ * <rootDir>/packages/server/src/__mocks__/utils/network.ts
118
+
119
+ [test:storybook-test] Storybook interaction tests completed.
120
+ ---- test finished (113.409s) ----
121
+ -------------------- Finished (113.416s) --------------------
@@ -9,6 +9,7 @@ const meta: Meta<typeof EnvironmentEditPanel> = {
9
9
  args: {
10
10
  mode: "new",
11
11
  environments: [],
12
+ githubAccounts: [],
12
13
  onAddEnvironment: fn(),
13
14
  onUpdateEnvironment: fn(),
14
15
  onListCodespaces: fn(),
@@ -1,6 +1,6 @@
1
1
  import { useState, useCallback, type JSX } from "react";
2
2
  import type { ToastVariant } from "../../context/ToastContext.js";
3
- import type { Environment, Codespace } from "../../hooks/types.js";
3
+ import type { Environment, Codespace, GitHubAccountData } from "../../hooks/types.js";
4
4
  import { ENVIRONMENTS_URL, environmentUrl, useAppNavigate } from "../../utils/navigation.js";
5
5
  import { EditableTextField } from "../editable/EditableTextField.js";
6
6
  import styles from "./EnvironmentEditPanel.module.scss";
@@ -17,12 +17,14 @@ interface Props {
17
17
  environmentId?: string;
18
18
  /** All environments (for lookup in edit mode). */
19
19
  environments: Environment[];
20
+ /** All registered GitHub accounts for the account selector. */
21
+ githubAccounts: GitHubAccountData[];
20
22
  /** Callback to add a new environment. */
21
- onAddEnvironment: (displayName: string, adapterType: string, adapterConfig?: Record<string, unknown>) => void;
23
+ onAddEnvironment: (displayName: string, adapterType: string, adapterConfig?: Record<string, unknown>, githubAccountId?: string) => void;
22
24
  /** Callback to update an existing environment. */
23
- onUpdateEnvironment: (environmentId: string, fields: { displayName?: string; adapterConfig?: Record<string, unknown> }) => void;
24
- /** Callback to list available codespaces. */
25
- onListCodespaces: () => void;
25
+ onUpdateEnvironment: (environmentId: string, fields: { displayName?: string; adapterConfig?: Record<string, unknown>; githubAccountId?: string }) => void;
26
+ /** Callback to list available codespaces, optionally filtered by GitHub account. */
27
+ onListCodespaces: (githubAccountId?: string) => void;
26
28
  /** Available codespaces. */
27
29
  codespaces: Codespace[];
28
30
  /** Error from codespace operations. */
@@ -200,7 +202,7 @@ function CodespacePicker({ codespaceName, onCodespaceNameChange, envName, onEnvN
200
202
  * - edit: pre-populated form; uses click-to-edit fields that auto-save via
201
203
  * updateEnvironment.
202
204
  */
203
- export function EnvironmentEditPanel({ mode, environmentId, environments, onAddEnvironment, onUpdateEnvironment, onListCodespaces, codespaces, codespaceError, codespaceListError, codespaceCreating, onCreateCodespace, onShowToast }: Props): JSX.Element {
205
+ export function EnvironmentEditPanel({ mode, environmentId, environments, githubAccounts, onAddEnvironment, onUpdateEnvironment, onListCodespaces, codespaces, codespaceError, codespaceListError, codespaceCreating, onCreateCodespace, onShowToast }: Props): JSX.Element {
204
206
  const navigate = useAppNavigate();
205
207
 
206
208
  const isEdit = mode === "edit";
@@ -219,6 +221,7 @@ export function EnvironmentEditPanel({ mode, environmentId, environments, onAddE
219
221
  const [image, setImage] = useState("");
220
222
  const [repo, setRepo] = useState("");
221
223
  const [codespaceName, setCodespaceName] = useState("");
224
+ const [githubAccountId, setGithubAccountId] = useState("");
222
225
 
223
226
  // ─── Edit mode state ───────────────────────────────
224
227
 
@@ -286,7 +289,7 @@ export function EnvironmentEditPanel({ mode, environmentId, environments, onAddE
286
289
  if (!isCreateValid()) {
287
290
  return;
288
291
  }
289
- onAddEnvironment(envName.trim(), adapterType, buildCreateConfig());
292
+ onAddEnvironment(envName.trim(), adapterType, buildCreateConfig(), githubAccountId || undefined);
290
293
  onShowToast?.("Environment added successfully", "success");
291
294
  navigate(ENVIRONMENTS_URL, { replace: true });
292
295
  };
@@ -406,6 +409,32 @@ export function EnvironmentEditPanel({ mode, environmentId, environments, onAddE
406
409
  </span>
407
410
  </div>
408
411
 
412
+ {/* GitHub Account (codespace and docker only). Show when accounts are
413
+ registered OR when the env already has an account association so
414
+ the user can clear it even if the referenced account was removed. */}
415
+ {(existingEnv.adapterType === "codespace" || existingEnv.adapterType === "docker") && (githubAccounts.length > 0 || Boolean(existingEnv.githubAccountId)) && (
416
+ <div className={styles.section}>
417
+ <label className={styles.label}>GitHub Account</label>
418
+ <select
419
+ value={existingEnv.githubAccountId || ""}
420
+ onChange={(e) => {
421
+ if (environmentId) {
422
+ onUpdateEnvironment(environmentId, { githubAccountId: e.target.value });
423
+ }
424
+ }}
425
+ className={styles.adapterSelect}
426
+ data-testid="env-edit-github-account"
427
+ >
428
+ <option value="">(Default)</option>
429
+ {githubAccounts.map((a) => (
430
+ <option key={a.id} value={a.id}>
431
+ {a.label}{a.username ? ` (@${a.username})` : ""}{a.isDefault ? " — default" : ""}
432
+ </option>
433
+ ))}
434
+ </select>
435
+ </div>
436
+ )}
437
+
409
438
  {/* Adapter-specific editable fields */}
410
439
  {existingEnv.adapterType === "local" && (
411
440
  <>
@@ -620,7 +649,7 @@ export function EnvironmentEditPanel({ mode, environmentId, environments, onAddE
620
649
  onChange={(e) => {
621
650
  setAdapterType(e.target.value);
622
651
  if (e.target.value === "codespace") {
623
- onListCodespaces();
652
+ onListCodespaces(githubAccountId || undefined);
624
653
  }
625
654
  }}
626
655
  className={styles.adapterSelect}
@@ -633,6 +662,34 @@ export function EnvironmentEditPanel({ mode, environmentId, environments, onAddE
633
662
  </select>
634
663
  </div>
635
664
 
665
+ {/* GitHub Account (codespace and docker only) */}
666
+ {(adapterType === "codespace" || adapterType === "docker") && githubAccounts.length > 0 && (
667
+ <div className={styles.section}>
668
+ <label className={styles.label} htmlFor="env-create-github-account">
669
+ GitHub Account
670
+ </label>
671
+ <select
672
+ id="env-create-github-account"
673
+ value={githubAccountId}
674
+ onChange={(e) => {
675
+ setGithubAccountId(e.target.value);
676
+ if (adapterType === "codespace") {
677
+ onListCodespaces(e.target.value || undefined);
678
+ }
679
+ }}
680
+ className={styles.adapterSelect}
681
+ data-testid="env-create-github-account"
682
+ >
683
+ <option value="">(Default)</option>
684
+ {githubAccounts.map((a) => (
685
+ <option key={a.id} value={a.id}>
686
+ {a.label} (@{a.username}){a.isDefault ? " — default" : ""}
687
+ </option>
688
+ ))}
689
+ </select>
690
+ </div>
691
+ )}
692
+
636
693
  {/* Adapter-specific fields */}
637
694
  {adapterType === "local" && (
638
695
  <>
@@ -0,0 +1,223 @@
1
+ import { useState, type JSX, type FormEvent } from "react";
2
+ import { X, Star } from "lucide-react";
3
+ import type { ToastVariant } from "../../context/ToastContext.js";
4
+ import type { GitHubAccountData } from "../../hooks/types.js";
5
+ import { ICON_MD } from "../../utils/iconSize.js";
6
+ import { ConfirmDialog } from "../display/index.js";
7
+ import styles from "./SettingsPanel.module.scss";
8
+
9
+ /** Props for the GitHubAccountsPanel component. */
10
+ export interface GitHubAccountsPanelProps {
11
+ /** All registered GitHub accounts. */
12
+ githubAccounts: GitHubAccountData[];
13
+ /** Whether the account list is loading. */
14
+ githubAccountsLoading: boolean;
15
+ /** Register a new GitHub account. Username will be resolved server-side if empty. */
16
+ onAddGitHubAccount: (label: string, token: string, username: string, isDefault: boolean) => Promise<void>;
17
+ /** Update an existing GitHub account. */
18
+ onUpdateGitHubAccount: (id: string, fields: { label?: string; token?: string; isDefault?: boolean }) => Promise<void>;
19
+ /** Remove a GitHub account by ID. */
20
+ onRemoveGitHubAccount: (id: string) => Promise<void>;
21
+ /** Import accounts from the local gh CLI authentication state. */
22
+ onImportGitHubAccounts: () => Promise<{ imported: number; usernames: string[] }>;
23
+ /** Display a toast notification. */
24
+ onShowToast?: (message: string, variant?: ToastVariant) => void;
25
+ }
26
+
27
+ /** Settings panel for managing registered GitHub accounts. */
28
+ export function GitHubAccountsPanel({
29
+ githubAccounts,
30
+ githubAccountsLoading,
31
+ onAddGitHubAccount,
32
+ onUpdateGitHubAccount,
33
+ onRemoveGitHubAccount,
34
+ onImportGitHubAccounts,
35
+ onShowToast,
36
+ }: GitHubAccountsPanelProps): JSX.Element {
37
+
38
+ const [label, setLabel] = useState("");
39
+ const [token, setToken] = useState("");
40
+ const [isDefault, setIsDefault] = useState(false);
41
+ const [adding, setAdding] = useState(false);
42
+ const [importing, setImporting] = useState(false);
43
+ const [confirmRemoveId, setConfirmRemoveId] = useState<string | null>(null);
44
+
45
+ const confirmRemoveAccount = githubAccounts.find((a) => a.id === confirmRemoveId);
46
+
47
+ const handleSubmit = async (e: FormEvent): Promise<void> => {
48
+ e.preventDefault();
49
+ if (!label.trim() || !token.trim()) {
50
+ return;
51
+ }
52
+ setAdding(true);
53
+ try {
54
+ await onAddGitHubAccount(label.trim(), token.trim(), "", isDefault);
55
+ onShowToast?.("GitHub account added", "success");
56
+ setLabel("");
57
+ setToken("");
58
+ setIsDefault(false);
59
+ } catch (err) {
60
+ const msg = err instanceof Error ? err.message : "Failed to add account";
61
+ onShowToast?.(msg, "error");
62
+ } finally {
63
+ setAdding(false);
64
+ }
65
+ };
66
+
67
+ const handleSetDefault = async (id: string): Promise<void> => {
68
+ try {
69
+ await onUpdateGitHubAccount(id, { isDefault: true });
70
+ onShowToast?.("Default account updated", "success");
71
+ } catch (err) {
72
+ const msg = err instanceof Error ? err.message : "Failed to update account";
73
+ onShowToast?.(msg, "error");
74
+ }
75
+ };
76
+
77
+ const handleConfirmRemove = async (): Promise<void> => {
78
+ if (!confirmRemoveId) {
79
+ return;
80
+ }
81
+ try {
82
+ await onRemoveGitHubAccount(confirmRemoveId);
83
+ onShowToast?.("GitHub account removed", "info");
84
+ } catch (err) {
85
+ const msg = err instanceof Error ? err.message : "Failed to remove account";
86
+ onShowToast?.(msg, "error");
87
+ } finally {
88
+ setConfirmRemoveId(null);
89
+ }
90
+ };
91
+
92
+ const handleImport = async (): Promise<void> => {
93
+ setImporting(true);
94
+ try {
95
+ const result = await onImportGitHubAccounts();
96
+ if (result.imported > 0) {
97
+ onShowToast?.(`Imported ${result.imported} account(s): ${result.usernames.join(", ")}`, "success");
98
+ } else {
99
+ onShowToast?.("No new accounts to import", "info");
100
+ }
101
+ } catch (err) {
102
+ const msg = err instanceof Error ? err.message : "Import failed";
103
+ onShowToast?.(msg, "error");
104
+ } finally {
105
+ setImporting(false);
106
+ }
107
+ };
108
+
109
+ return (
110
+ <>
111
+ <ConfirmDialog
112
+ isOpen={confirmRemoveId !== null}
113
+ title="Remove GitHub Account?"
114
+ description={
115
+ confirmRemoveAccount
116
+ ? `"${confirmRemoveAccount.label}"${confirmRemoveAccount.username ? ` (@${confirmRemoveAccount.username})` : ""} will be permanently removed.`
117
+ : undefined
118
+ }
119
+ onConfirm={() => { handleConfirmRemove().catch(() => {}); }}
120
+ onCancel={() => setConfirmRemoveId(null)}
121
+ />
122
+
123
+ <section className={styles.section} data-testid="github-accounts-panel">
124
+ <h3 className={styles.sectionTitle}>GitHub Accounts</h3>
125
+ <p className={styles.sectionDescription}>
126
+ Register multiple GitHub accounts to use different identities per environment.
127
+ The default account is used when no specific account is assigned.
128
+ </p>
129
+
130
+ {githubAccountsLoading && githubAccounts.length === 0 ? (
131
+ <div className={styles.emptyState}>Loading...</div>
132
+ ) : githubAccounts.length === 0 ? (
133
+ <div className={styles.emptyStateInfo}>
134
+ No GitHub accounts registered. Add one below or import from the gh CLI.
135
+ </div>
136
+ ) : (
137
+ <div className={styles.tokenList}>
138
+ {githubAccounts.map((account) => (
139
+ <div key={account.id} className={styles.tokenRow} data-testid={`github-account-row-${account.id}`}>
140
+ {account.isDefault && (
141
+ <span className={styles.tokenBadge} title="Default account">default</span>
142
+ )}
143
+ <span className={styles.tokenName}>{account.label}</span>
144
+ {account.username && (
145
+ <span className={styles.tokenTarget}>@{account.username}</span>
146
+ )}
147
+ {!account.isDefault && (
148
+ <button
149
+ className={styles.deleteButton}
150
+ onClick={() => { handleSetDefault(account.id).catch(() => {}); }}
151
+ title="Set as default"
152
+ aria-label={`Set ${account.label} as default`}
153
+ data-testid={`github-account-set-default-${account.id}`}
154
+ >
155
+ <Star size={ICON_MD} aria-hidden="true" />
156
+ </button>
157
+ )}
158
+ <button
159
+ className={styles.deleteButton}
160
+ onClick={() => setConfirmRemoveId(account.id)}
161
+ title={`Remove ${account.label}`}
162
+ aria-label={`Remove ${account.label}`}
163
+ data-testid={`github-account-remove-${account.id}`}
164
+ >
165
+ <X size={ICON_MD} aria-hidden="true" />
166
+ </button>
167
+ </div>
168
+ ))}
169
+ </div>
170
+ )}
171
+
172
+ <form className={styles.addForm} onSubmit={(e) => { handleSubmit(e).catch(() => {}); }}>
173
+ <div className={styles.formRow}>
174
+ <input
175
+ className={styles.input}
176
+ type="text"
177
+ placeholder="Label (e.g. personal, work)"
178
+ value={label}
179
+ onChange={(e) => setLabel(e.target.value)}
180
+ data-testid="github-account-label-input"
181
+ />
182
+ <input
183
+ className={styles.input}
184
+ type="password"
185
+ placeholder="Personal access token"
186
+ value={token}
187
+ onChange={(e) => setToken(e.target.value)}
188
+ data-testid="github-account-token-input"
189
+ />
190
+ </div>
191
+ <div className={styles.formRow}>
192
+ <label className={styles.tokenTarget} style={{ display: "flex", alignItems: "center", gap: "6px", cursor: "pointer" }}>
193
+ <input
194
+ type="checkbox"
195
+ checked={isDefault}
196
+ onChange={(e) => setIsDefault(e.target.checked)}
197
+ data-testid="github-account-default-checkbox"
198
+ />
199
+ Set as default
200
+ </label>
201
+ <button
202
+ className={styles.addButton}
203
+ type="submit"
204
+ disabled={!label.trim() || !token.trim() || adding}
205
+ data-testid="github-account-add-button"
206
+ >
207
+ {adding ? "Adding..." : "Add Account"}
208
+ </button>
209
+ <button
210
+ className={styles.addButton}
211
+ type="button"
212
+ onClick={() => { handleImport().catch(() => {}); }}
213
+ disabled={importing}
214
+ data-testid="github-account-import-button"
215
+ >
216
+ {importing ? "Importing..." : "Import from gh CLI"}
217
+ </button>
218
+ </div>
219
+ </form>
220
+ </section>
221
+ </>
222
+ );
223
+ }
@@ -11,3 +11,5 @@ export { TaskActionButtons } from "./TaskActionButtons.js";
11
11
  export { TaskOverviewPanel } from "./TaskOverviewPanel.js";
12
12
  export { PluginsPanel } from "./PluginsPanel.js";
13
13
  export type { PluginsPanelProps } from "./PluginsPanel.js";
14
+ export { GitHubAccountsPanel } from "./GitHubAccountsPanel.js";
15
+ export type { GitHubAccountsPanelProps } from "./GitHubAccountsPanel.js";
@@ -9,10 +9,11 @@ const meta: Meta<typeof SettingsNav> = {
9
9
  export default meta;
10
10
  type Story = StoryObj<typeof meta>;
11
11
 
12
- /** All five tabs are rendered with correct labels. */
12
+ /** All tabs are rendered with correct labels. */
13
13
  export const AllTabsRendered: Story = {
14
14
  play: async ({ canvas }) => {
15
15
  await expect(canvas.getByRole("tab", { name: /Credentials/ })).toBeInTheDocument();
16
+ await expect(canvas.getByRole("tab", { name: /GitHub Accounts/ })).toBeInTheDocument();
16
17
  await expect(canvas.getByRole("tab", { name: /Personas/ })).toBeInTheDocument();
17
18
  await expect(canvas.getByRole("tab", { name: /Schedules/ })).toBeInTheDocument();
18
19
  await expect(canvas.getByRole("tab", { name: /Appearance/ })).toBeInTheDocument();
@@ -35,15 +36,15 @@ export const KeyboardNavigation: Story = {
35
36
  const credentialsTab = canvas.getByRole("tab", { name: /Credentials/ });
36
37
  credentialsTab.focus();
37
38
 
38
- // ArrowDown should move to Personas
39
+ // ArrowDown should move to GitHub (now second tab)
39
40
  await userEvent.keyboard("{ArrowDown}");
40
- const personasTab = canvas.getByRole("tab", { name: /Personas/ });
41
- await expect(personasTab).toHaveFocus();
41
+ const githubTab = canvas.getByRole("tab", { name: /GitHub Accounts/ });
42
+ await expect(githubTab).toHaveFocus();
42
43
 
43
- // ArrowDown again to Schedules
44
+ // ArrowDown again to Personas
44
45
  await userEvent.keyboard("{ArrowDown}");
45
- const schedulesTab = canvas.getByRole("tab", { name: /Schedules/ });
46
- await expect(schedulesTab).toHaveFocus();
46
+ const personasTab = canvas.getByRole("tab", { name: /Personas/ });
47
+ await expect(personasTab).toHaveFocus();
47
48
 
48
49
  // Home goes to first tab
49
50
  await userEvent.keyboard("{Home}");
@@ -62,10 +63,10 @@ export const JKNavigation: Story = {
62
63
  const credentialsTab = canvas.getByRole("tab", { name: /Credentials/ });
63
64
  credentialsTab.focus();
64
65
 
65
- // J moves down to Personas
66
+ // J moves down to GitHub (now second tab)
66
67
  await userEvent.keyboard("j");
67
- const personasTab = canvas.getByRole("tab", { name: /Personas/ });
68
- await expect(personasTab).toHaveFocus();
68
+ const githubTab = canvas.getByRole("tab", { name: /GitHub Accounts/ });
69
+ await expect(githubTab).toHaveFocus();
69
70
 
70
71
  // K moves back up to Credentials
71
72
  await userEvent.keyboard("k");
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useRef, type JSX, type KeyboardEvent, type ReactNode } from "react";
2
2
  import { useLocation } from "react-router";
3
- import { CalendarClock, Info, Key, Keyboard, Palette, Puzzle, User } from "lucide-react";
3
+ import { CalendarClock, Github, Info, Key, Keyboard, Palette, Puzzle, User } from "lucide-react";
4
4
  import { SETTINGS_URL, useAppNavigate } from "../../utils/navigation.js";
5
5
  import { ICON_LG } from "../../utils/iconSize.js";
6
6
  import styles from "./SettingsNav.module.scss";
@@ -18,6 +18,7 @@ interface SettingsTab {
18
18
  /** Ordered list of settings tabs. */
19
19
  const TABS: SettingsTab[] = [
20
20
  { path: "credentials", label: "Credentials", icon: <Key size={ICON_LG} /> },
21
+ { path: "github-accounts", label: "GitHub Accounts", icon: <Github size={ICON_LG} /> },
21
22
  { path: "personas", label: "Personas", icon: <User size={ICON_LG} /> },
22
23
  { path: "schedules", label: "Schedules", icon: <CalendarClock size={ICON_LG} /> },
23
24
  { path: "appearance", label: "Appearance", icon: <Palette size={ICON_LG} /> },
@@ -14,6 +14,7 @@ import type {
14
14
  UsePluginsResult,
15
15
  UseSchedulesResult,
16
16
  UseStreamsResult,
17
+ UseGitHubAccountsResult,
17
18
  ConnectionStatus,
18
19
  } from "../hooks/types.js";
19
20
 
@@ -47,6 +48,8 @@ export interface UseGrackleSocketResult {
47
48
  knowledge: Omit<UseKnowledgeResult, "handleEvent">;
48
49
  /** Plugin state and actions. */
49
50
  plugins: Omit<UsePluginsResult, "domainHook">;
51
+ /** GitHub account state and actions. */
52
+ githubAccounts: Omit<UseGitHubAccountsResult, "domainHook">;
50
53
  /** App-level default persona ID setting. */
51
54
  appDefaultPersonaId: string;
52
55
  /** Update the app-level default persona ID. */