@vibe80/vibe80 0.2.0 → 0.2.2

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 (55) hide show
  1. package/README.md +132 -16
  2. package/bin/vibe80.js +1728 -16
  3. package/client/dist/assets/{DiffPanel-BKLnyIAZ.js → DiffPanel-BUJhQj_Q.js} +1 -1
  4. package/client/dist/assets/{ExplorerPanel-D3IbBsXz.js → ExplorerPanel-DugEeaO2.js} +1 -1
  5. package/client/dist/assets/{LogsPanel-BwJAFHRP.js → LogsPanel-BQrGxMu_.js} +1 -1
  6. package/client/dist/assets/{SettingsPanel-BfkchMnR.js → SettingsPanel-Ci2BdIYO.js} +1 -1
  7. package/client/dist/assets/{TerminalPanel-BQfMEm-u.js → TerminalPanel-C-T3t-6T.js} +1 -1
  8. package/client/dist/assets/index-cFi4LM0j.js +711 -0
  9. package/client/dist/assets/index-qNyFxUjK.css +32 -0
  10. package/client/dist/icon_square-512x512.png +0 -0
  11. package/client/dist/icon_square.svg +58 -0
  12. package/client/dist/index.html +3 -2
  13. package/client/dist/sw.js +1 -1
  14. package/client/index.html +1 -0
  15. package/client/public/icon_square-512x512.png +0 -0
  16. package/client/public/icon_square.svg +58 -0
  17. package/client/src/App.jsx +205 -2
  18. package/client/src/assets/vibe80_dark.png +0 -0
  19. package/client/src/assets/vibe80_light.png +0 -0
  20. package/client/src/components/Chat/ChatMessages.jsx +1 -1
  21. package/client/src/components/SessionGate/SessionGate.jsx +295 -91
  22. package/client/src/components/WorktreeTabs.css +11 -0
  23. package/client/src/components/WorktreeTabs.jsx +77 -47
  24. package/client/src/hooks/useChatSocket.js +8 -7
  25. package/client/src/hooks/useRepoBranchesModels.js +12 -6
  26. package/client/src/hooks/useWorktreeCloseConfirm.js +19 -7
  27. package/client/src/hooks/useWorktrees.js +3 -1
  28. package/client/src/index.css +26 -3
  29. package/client/src/locales/en.json +12 -1
  30. package/client/src/locales/fr.json +12 -1
  31. package/docs/api/openapi.json +1 -1
  32. package/package.json +2 -1
  33. package/server/scripts/rotate-workspace-secret.js +1 -1
  34. package/server/src/claudeClient.js +3 -3
  35. package/server/src/codexClient.js +3 -3
  36. package/server/src/config.js +6 -6
  37. package/server/src/index.js +14 -12
  38. package/server/src/middleware/auth.js +7 -7
  39. package/server/src/middleware/debug.js +36 -4
  40. package/server/src/providerLogger.js +2 -2
  41. package/server/src/routes/sessions.js +133 -21
  42. package/server/src/routes/workspaces.js +1 -1
  43. package/server/src/runAs.js +14 -14
  44. package/server/src/services/auth.js +3 -3
  45. package/server/src/services/session.js +182 -14
  46. package/server/src/services/workspace.js +86 -42
  47. package/server/src/storage/index.js +2 -2
  48. package/server/src/storage/redis.js +38 -36
  49. package/server/src/storage/sqlite.js +13 -13
  50. package/server/src/worktreeManager.js +87 -19
  51. package/server/tests/integration/routes/workspaces-routes.test.js +8 -8
  52. package/server/tests/setup/env.js +5 -5
  53. package/server/tests/unit/services/auth.test.js +3 -3
  54. package/client/dist/assets/index-BDQQz6SJ.css +0 -32
  55. package/client/dist/assets/index-D1UJw1oP.js +0 -711
@@ -1,6 +1,15 @@
1
1
  import React from "react";
2
2
  import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
3
- import { faCheck, faCopy, faPlus, faRightFromBracket, faUser } from "@fortawesome/free-solid-svg-icons";
3
+ import {
4
+ faCheck,
5
+ faCopy,
6
+ faPlus,
7
+ faRightFromBracket,
8
+ faGear,
9
+ faSpinner,
10
+ faTrash,
11
+ faUser,
12
+ } from "@fortawesome/free-solid-svg-icons";
4
13
 
5
14
  export default function SessionGate({
6
15
  t,
@@ -40,7 +49,14 @@ export default function SessionGate({
40
49
  workspaceSessions,
41
50
  workspaceSessionsError,
42
51
  workspaceSessionDeletingId,
52
+ workspaceSessionConfigId,
53
+ sessionConfigTarget,
54
+ workspaceSessionUpdatingId,
55
+ workspaceSessionConfigError,
43
56
  handleResumeSession,
57
+ openSessionConfigure,
58
+ closeSessionConfigure,
59
+ handleUpdateAndResumeSession,
44
60
  handleDeleteSession,
45
61
  locale,
46
62
  extractRepoName,
@@ -65,6 +81,18 @@ export default function SessionGate({
65
81
  setDefaultInternetAccess,
66
82
  defaultDenyGitCredentialsAccess,
67
83
  setDefaultDenyGitCredentialsAccess,
84
+ sessionConfigAuthMode,
85
+ setSessionConfigAuthMode,
86
+ sessionConfigSshKey,
87
+ setSessionConfigSshKey,
88
+ sessionConfigHttpUsername,
89
+ setSessionConfigHttpUsername,
90
+ sessionConfigHttpPassword,
91
+ setSessionConfigHttpPassword,
92
+ sessionConfigInternetAccess,
93
+ setSessionConfigInternetAccess,
94
+ sessionConfigDenyGitCredentialsAccess,
95
+ setSessionConfigDenyGitCredentialsAccess,
68
96
  attachmentsError,
69
97
  sessionRequested,
70
98
  workspaceBusy,
@@ -476,84 +504,232 @@ export default function SessionGate({
476
504
  }`}
477
505
  aria-hidden={sessionMode !== "existing"}
478
506
  >
479
- <div className="session-auth">
480
- <div className="session-auth-title">
481
- {t("Existing sessions")}
507
+ {!workspaceSessionConfigId ? (
508
+ <div className="session-auth">
509
+ <div className="session-auth-title">
510
+ {t("Existing sessions")}
511
+ </div>
512
+ {workspaceSessionsLoading ? (
513
+ <div className="session-auth-hint">
514
+ {t("Loading sessions...")}
515
+ </div>
516
+ ) : workspaceSessions.length === 0 ? (
517
+ <div className="session-auth-hint">
518
+ {t("No sessions available.")}
519
+ </div>
520
+ ) : (
521
+ <ul className="session-list">
522
+ {workspaceSessions.map((session) => {
523
+ const repoName = extractRepoName(session.repoUrl);
524
+ const title =
525
+ session.name || repoName || session.sessionId;
526
+ const subtitle = session.repoUrl
527
+ ? getTruncatedText(session.repoUrl, 72)
528
+ : session.sessionId;
529
+ const lastSeen = session.lastActivityAt
530
+ ? new Date(session.lastActivityAt).toLocaleString(
531
+ locale
532
+ )
533
+ : session.createdAt
534
+ ? new Date(
535
+ session.createdAt
536
+ ).toLocaleString(locale)
537
+ : "";
538
+ const isDeleting =
539
+ workspaceSessionDeletingId === session.sessionId;
540
+ const isUpdating =
541
+ workspaceSessionUpdatingId === session.sessionId;
542
+ return (
543
+ <li key={session.sessionId} className="session-item">
544
+ <div className="session-item-row">
545
+ <div className="session-item-meta">
546
+ <div className="session-item-title">{title}</div>
547
+ <div className="session-item-sub">
548
+ {subtitle}
549
+ </div>
550
+ {lastSeen && (
551
+ <div className="session-item-sub">
552
+ {t("Last activity: {{date}}", {
553
+ date: lastSeen,
554
+ })}
555
+ </div>
556
+ )}
557
+ </div>
558
+ <div className="session-item-actions">
559
+ <button
560
+ type="button"
561
+ className="session-list-button session-list-icon-button"
562
+ onClick={() =>
563
+ handleResumeSession(session.sessionId)
564
+ }
565
+ disabled={formDisabled || isDeleting}
566
+ title={t("Resume")}
567
+ aria-label={t("Resume")}
568
+ >
569
+ <FontAwesomeIcon icon={faRightFromBracket} />
570
+ </button>
571
+ <button
572
+ type="button"
573
+ className="session-list-button session-list-icon-button"
574
+ onClick={() => openSessionConfigure(session)}
575
+ disabled={formDisabled || isDeleting || isUpdating}
576
+ title={t("Configure session")}
577
+ aria-label={t("Configure session")}
578
+ >
579
+ <FontAwesomeIcon icon={faGear} />
580
+ </button>
581
+ <button
582
+ type="button"
583
+ className="session-list-button session-list-icon-button is-danger"
584
+ onClick={() => handleDeleteSession(session)}
585
+ disabled={formDisabled || isDeleting}
586
+ title={isDeleting ? t("Deleting...") : t("Delete")}
587
+ aria-label={isDeleting ? t("Deleting...") : t("Delete")}
588
+ >
589
+ <FontAwesomeIcon
590
+ icon={isDeleting ? faSpinner : faTrash}
591
+ spin={isDeleting}
592
+ />
593
+ </button>
594
+ </div>
595
+ </div>
596
+ </li>
597
+ );
598
+ })}
599
+ </ul>
600
+ )}
601
+ {workspaceSessionsError && (
602
+ <div className="attachments-error">
603
+ {workspaceSessionsError}
604
+ </div>
605
+ )}
482
606
  </div>
483
- {workspaceSessionsLoading ? (
484
- <div className="session-auth-hint">
485
- {t("Loading sessions...")}
607
+ ) : (
608
+ <div className="session-form session-form--compact">
609
+ <div className="session-auth-title">
610
+ {t("Configure session")}
486
611
  </div>
487
- ) : workspaceSessions.length === 0 ? (
488
- <div className="session-auth-hint">
489
- {t("No sessions available.")}
612
+ <div className="session-form-row is-compact-grid">
613
+ <input
614
+ type="text"
615
+ placeholder={t("Session name")}
616
+ value={sessionConfigTarget?.name || ""}
617
+ disabled
618
+ />
619
+ <div className="session-repo-field">
620
+ <input
621
+ type="text"
622
+ placeholder={t("git@gitea.devops:my-org/my-repo.git")}
623
+ value={sessionConfigTarget?.repoUrl || ""}
624
+ disabled
625
+ />
626
+ </div>
490
627
  </div>
491
- ) : (
492
- <ul className="session-list">
493
- {workspaceSessions.map((session) => {
494
- const repoName = extractRepoName(session.repoUrl);
495
- const title =
496
- session.name || repoName || session.sessionId;
497
- const subtitle = session.repoUrl
498
- ? getTruncatedText(session.repoUrl, 72)
499
- : session.sessionId;
500
- const lastSeen = session.lastActivityAt
501
- ? new Date(session.lastActivityAt).toLocaleString(
502
- locale
503
- )
504
- : session.createdAt
505
- ? new Date(
506
- session.createdAt
507
- ).toLocaleString(locale)
508
- : "";
509
- const isDeleting =
510
- workspaceSessionDeletingId === session.sessionId;
511
- return (
512
- <li key={session.sessionId} className="session-item">
513
- <div className="session-item-meta">
514
- <div className="session-item-title">{title}</div>
515
- <div className="session-item-sub">
516
- {subtitle}
517
- </div>
518
- {lastSeen && (
519
- <div className="session-item-sub">
520
- {t("Last activity: {{date}}", {
521
- date: lastSeen,
522
- })}
523
- </div>
524
- )}
525
- </div>
526
- <div className="session-item-actions">
527
- <button
528
- type="button"
529
- className="session-list-button"
530
- onClick={() =>
531
- handleResumeSession(session.sessionId)
532
- }
533
- disabled={formDisabled || isDeleting}
534
- >
535
- {t("Resume")}
536
- </button>
537
- <button
538
- type="button"
539
- className="session-list-button is-danger"
540
- onClick={() => handleDeleteSession(session)}
541
- disabled={formDisabled || isDeleting}
542
- >
543
- {isDeleting ? t("Deleting...") : t("Delete")}
544
- </button>
545
- </div>
546
- </li>
547
- );
548
- })}
549
- </ul>
550
- )}
551
- {workspaceSessionsError && (
552
- <div className="attachments-error">
553
- {workspaceSessionsError}
628
+ <div className="session-auth">
629
+ <div className="session-auth-title">
630
+ {t("Repository authentication (optional)")}
631
+ </div>
632
+ <div className="session-auth-options">
633
+ <select
634
+ value={sessionConfigAuthMode}
635
+ onChange={(event) =>
636
+ setSessionConfigAuthMode(event.target.value)
637
+ }
638
+ disabled={formDisabled || Boolean(workspaceSessionUpdatingId)}
639
+ >
640
+ <option value="keep">{t("Keep unchanged")}</option>
641
+ <option value="none">{t("None")}</option>
642
+ <option value="ssh">
643
+ {t("Private SSH key (not recommended)")}
644
+ </option>
645
+ <option value="http">
646
+ {t("Username + password")}
647
+ </option>
648
+ </select>
649
+ </div>
650
+ {sessionConfigAuthMode === "ssh" ? (
651
+ <textarea
652
+ className="session-auth-textarea"
653
+ placeholder={t("-----BEGIN OPENSSH PRIVATE KEY-----")}
654
+ value={sessionConfigSshKey}
655
+ onChange={(event) =>
656
+ setSessionConfigSshKey(event.target.value)
657
+ }
658
+ disabled={formDisabled || Boolean(workspaceSessionUpdatingId)}
659
+ rows={6}
660
+ spellCheck={false}
661
+ />
662
+ ) : null}
663
+ {sessionConfigAuthMode === "http" ? (
664
+ <div className="session-auth-grid">
665
+ <input
666
+ type="text"
667
+ placeholder={t("Username")}
668
+ value={sessionConfigHttpUsername}
669
+ onChange={(event) =>
670
+ setSessionConfigHttpUsername(event.target.value)
671
+ }
672
+ disabled={formDisabled || Boolean(workspaceSessionUpdatingId)}
673
+ autoComplete="username"
674
+ />
675
+ <input
676
+ type="password"
677
+ placeholder={t("Password or PAT")}
678
+ value={sessionConfigHttpPassword}
679
+ onChange={(event) =>
680
+ setSessionConfigHttpPassword(event.target.value)
681
+ }
682
+ disabled={formDisabled || Boolean(workspaceSessionUpdatingId)}
683
+ autoComplete="current-password"
684
+ />
685
+ </div>
686
+ ) : null}
554
687
  </div>
555
- )}
556
- </div>
688
+ <div className="session-auth session-auth-compact">
689
+ <div className="session-auth-title">
690
+ {t("Permissions")}
691
+ </div>
692
+ <div className="session-auth-options session-auth-options--compact">
693
+ <label className="session-auth-option">
694
+ <input
695
+ type="checkbox"
696
+ checked={sessionConfigInternetAccess}
697
+ onChange={(event) => {
698
+ const checked = event.target.checked;
699
+ setSessionConfigInternetAccess(checked);
700
+ if (!checked) {
701
+ setSessionConfigDenyGitCredentialsAccess(false);
702
+ }
703
+ }}
704
+ disabled={formDisabled || Boolean(workspaceSessionUpdatingId)}
705
+ />
706
+ {t("Internet access")}
707
+ </label>
708
+ {sessionConfigInternetAccess &&
709
+ deploymentMode !== "mono_user" ? (
710
+ <label className="session-auth-option">
711
+ <input
712
+ type="checkbox"
713
+ checked={sessionConfigDenyGitCredentialsAccess}
714
+ onChange={(event) =>
715
+ setSessionConfigDenyGitCredentialsAccess(
716
+ event.target.checked
717
+ )
718
+ }
719
+ disabled={formDisabled || Boolean(workspaceSessionUpdatingId)}
720
+ />
721
+ {t("Deny git credentials access")}
722
+ </label>
723
+ ) : null}
724
+ </div>
725
+ </div>
726
+ {workspaceSessionConfigError ? (
727
+ <div className="attachments-error">
728
+ {workspaceSessionConfigError}
729
+ </div>
730
+ ) : null}
731
+ </div>
732
+ )}
557
733
  </div>
558
734
  <div
559
735
  className={`session-panel ${
@@ -788,21 +964,37 @@ export default function SessionGate({
788
964
  </button>
789
965
  ) : showStep4 ? (
790
966
  sessionMode === "existing" ? (
791
- <button
792
- type="button"
793
- className="session-button secondary session-footer-full"
794
- disabled={formDisabled}
795
- onClick={() => {
796
- setWorkspaceProvidersEditing(true);
797
- setWorkspaceError("");
798
- setProvidersBackStep(4);
799
- loadWorkspaceProviders();
800
- loadWorkspaceSessions();
801
- setWorkspaceStep(2);
802
- }}
803
- >
804
- {t("AI providers")}
805
- </button>
967
+ <>
968
+ <button
969
+ type="button"
970
+ className="session-button secondary"
971
+ disabled={formDisabled}
972
+ onClick={
973
+ workspaceSessionConfigId
974
+ ? closeSessionConfigure
975
+ : () => {
976
+ setWorkspaceProvidersEditing(true);
977
+ setWorkspaceError("");
978
+ setProvidersBackStep(4);
979
+ loadWorkspaceProviders();
980
+ loadWorkspaceSessions();
981
+ setWorkspaceStep(2);
982
+ }
983
+ }
984
+ >
985
+ {workspaceSessionConfigId ? t("Cancel") : t("AI providers")}
986
+ </button>
987
+ {workspaceSessionConfigId ? (
988
+ <button
989
+ type="button"
990
+ className="session-button primary"
991
+ disabled={formDisabled || Boolean(workspaceSessionUpdatingId)}
992
+ onClick={handleUpdateAndResumeSession}
993
+ >
994
+ {workspaceSessionUpdatingId ? t("Updating...") : t("Update & Resume")}
995
+ </button>
996
+ ) : null}
997
+ </>
806
998
  ) : (
807
999
  <>
808
1000
  <button
@@ -844,6 +1036,18 @@ export default function SessionGate({
844
1036
  {infoContent.paragraphs?.map((paragraph) => (
845
1037
  <p key={paragraph}>{paragraph}</p>
846
1038
  ))}
1039
+ {infoContent.setupLink ? (
1040
+ <p>
1041
+ <a
1042
+ className="session-info-link"
1043
+ href="https://vibe80.io/docs/workspace-session-setup"
1044
+ target="_blank"
1045
+ rel="noreferrer"
1046
+ >
1047
+ {t("Click here to learn more.")}
1048
+ </a>
1049
+ </p>
1050
+ ) : null}
847
1051
  {infoContent.securityLink ? (
848
1052
  <p>
849
1053
  {t(
@@ -851,7 +1055,7 @@ export default function SessionGate({
851
1055
  )}
852
1056
  <a
853
1057
  className="session-info-link"
854
- href="https://vibe80.ai/security"
1058
+ href="https://vibe80.io/docs/sandboxing"
855
1059
  target="_blank"
856
1060
  rel="noreferrer"
857
1061
  >
@@ -258,6 +258,17 @@
258
258
  font-size: 18px;
259
259
  }
260
260
 
261
+ .worktree-create-form {
262
+ border: 0;
263
+ margin: 0;
264
+ padding: 0;
265
+ min-width: 0;
266
+ }
267
+
268
+ .worktree-create-dialog.is-submitting .worktree-create-form {
269
+ opacity: 0.55;
270
+ }
271
+
261
272
  .worktree-create-grid {
262
273
  display: grid;
263
274
  grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -69,6 +69,7 @@ export default function WorktreeTabs({
69
69
  const [newSourceWorktree, setNewSourceWorktree] = useState("main");
70
70
  const [newModel, setNewModel] = useState("");
71
71
  const [newReasoningEffort, setNewReasoningEffort] = useState("");
72
+ const [createSubmitting, setCreateSubmitting] = useState(false);
72
73
  const [newInternetAccess, setNewInternetAccess] = useState(
73
74
  Boolean(defaultInternetAccess)
74
75
  );
@@ -218,34 +219,46 @@ export default function WorktreeTabs({
218
219
  }
219
220
  }, [newContext, newProvider, selectedModelDetails, newReasoningEffort]);
220
221
 
221
- const handleCreate = () => {
222
- if (onCreate) {
223
- onCreate({
224
- context: newContext,
225
- name: newName.trim() || null,
226
- provider: newProvider,
227
- sourceWorktree: newContext === "fork" ? newSourceWorktree : null,
228
- startingBranch: effectiveBranch || null,
229
- model: newContext === "new" ? newModel || null : null,
230
- reasoningEffort: newContext === "new" ? newReasoningEffort || null : null,
231
- internetAccess: newInternetAccess,
232
- denyGitCredentialsAccess: newDenyGitCredentialsAccess,
233
- });
222
+ const handleCreate = async () => {
223
+ if (createSubmitting) {
224
+ return;
225
+ }
226
+ setCreateSubmitting(true);
227
+ try {
228
+ let created = true;
229
+ if (onCreate) {
230
+ created = await onCreate({
231
+ context: newContext,
232
+ name: newName.trim() || null,
233
+ provider: newProvider,
234
+ sourceWorktree: newContext === "fork" ? newSourceWorktree : null,
235
+ startingBranch: effectiveBranch || null,
236
+ model: newContext === "new" ? newModel || null : null,
237
+ reasoningEffort: newContext === "new" ? newReasoningEffort || null : null,
238
+ internetAccess: newInternetAccess,
239
+ denyGitCredentialsAccess: newDenyGitCredentialsAccess,
240
+ });
241
+ }
242
+ if (!created) {
243
+ return;
244
+ }
245
+ setNewName("");
246
+ setNewContext("new");
247
+ setNewProvider(providerOptions[0]);
248
+ setNewSourceWorktree("main");
249
+ setStartingBranch(defaultBranch || "");
250
+ setNewModel("");
251
+ setNewReasoningEffort("");
252
+ setNewInternetAccess(Boolean(defaultInternetAccess));
253
+ setNewDenyGitCredentialsAccess(
254
+ typeof defaultDenyGitCredentialsAccess === "boolean"
255
+ ? defaultDenyGitCredentialsAccess
256
+ : true
257
+ );
258
+ setCreateDialogOpen(false);
259
+ } finally {
260
+ setCreateSubmitting(false);
234
261
  }
235
- setNewName("");
236
- setNewContext("new");
237
- setNewProvider(providerOptions[0]);
238
- setNewSourceWorktree("main");
239
- setStartingBranch(defaultBranch || "");
240
- setNewModel("");
241
- setNewReasoningEffort("");
242
- setNewInternetAccess(Boolean(defaultInternetAccess));
243
- setNewDenyGitCredentialsAccess(
244
- typeof defaultDenyGitCredentialsAccess === "boolean"
245
- ? defaultDenyGitCredentialsAccess
246
- : true
247
- );
248
- setCreateDialogOpen(false);
249
262
  };
250
263
 
251
264
  const handleStartEdit = (wt) => {
@@ -271,8 +284,11 @@ export default function WorktreeTabs({
271
284
  };
272
285
 
273
286
  const handleKeyDownCreate = (e) => {
287
+ if (createSubmitting) {
288
+ return;
289
+ }
274
290
  if (e.key === "Enter") {
275
- handleCreate();
291
+ void handleCreate();
276
292
  } else if (e.key === "Escape") {
277
293
  setCreateDialogOpen(false);
278
294
  }
@@ -416,10 +432,23 @@ export default function WorktreeTabs({
416
432
  </div>
417
433
 
418
434
  {createDialogOpen && (
419
- <div className="worktree-create-dialog-overlay" onClick={() => setCreateDialogOpen(false)}>
420
- <div className="worktree-create-dialog" onClick={(e) => e.stopPropagation()}>
435
+ <div
436
+ className="worktree-create-dialog-overlay"
437
+ onClick={() => {
438
+ if (!createSubmitting) {
439
+ setCreateDialogOpen(false);
440
+ }
441
+ }}
442
+ >
443
+ <div
444
+ className={`worktree-create-dialog ${
445
+ createSubmitting ? "is-submitting" : ""
446
+ }`}
447
+ onClick={(e) => e.stopPropagation()}
448
+ >
421
449
  <h3>{t("New worktree")}</h3>
422
- <div className="worktree-create-grid">
450
+ <fieldset className="worktree-create-form" disabled={createSubmitting}>
451
+ <div className="worktree-create-grid">
423
452
  <div className="worktree-create-field">
424
453
  <label>{t("Name (optional)")}</label>
425
454
  <input
@@ -579,22 +608,23 @@ export default function WorktreeTabs({
579
608
  </label>
580
609
  </div>
581
610
  )}
582
- </div>
583
- <div className="worktree-create-actions">
584
- <button
585
- className="worktree-btn-cancel"
586
- onClick={() => setCreateDialogOpen(false)}
587
- >
588
- {t("Cancel")}
589
- </button>
590
- <button
591
- className="worktree-btn-create"
592
- onClick={handleCreate}
593
- disabled={!isBranchValid || (newContext === "fork" && !newSourceWorktree)}
594
- >
595
- {t("Create")}
596
- </button>
597
- </div>
611
+ </div>
612
+ <div className="worktree-create-actions">
613
+ <button
614
+ className="worktree-btn-cancel"
615
+ onClick={() => setCreateDialogOpen(false)}
616
+ >
617
+ {t("Cancel")}
618
+ </button>
619
+ <button
620
+ className="worktree-btn-create"
621
+ onClick={() => void handleCreate()}
622
+ disabled={!isBranchValid || (newContext === "fork" && !newSourceWorktree)}
623
+ >
624
+ {createSubmitting ? t("Creating...") : t("Create")}
625
+ </button>
626
+ </div>
627
+ </fieldset>
598
628
  </div>
599
629
  </div>
600
630
  )}