diff --git a/server/service/hid/hid_test.go b/server/service/hid/hid_test.go index 572793e..2b7239e 100644 --- a/server/service/hid/hid_test.go +++ b/server/service/hid/hid_test.go @@ -1,6 +1,9 @@ package hid -import "testing" +import ( + "testing" + "time" +) func TestReportLengthValidation(t *testing.T) { h := &Hid{} @@ -14,3 +17,12 @@ func TestReportLengthValidation(t *testing.T) { t.Fatal("expected absolute mouse length error") } } + +func TestPasteDurationLeavesModeSwitchMargin(t *testing.T) { + if maxPasteDuration >= 30*time.Second { + t.Fatalf("maxPasteDuration = %s, want below 30s mode switch wait budget", maxPasteDuration) + } + if got := time.Duration(maxPasteContentRunes) * defaultPasteDelay; got > maxPasteDuration { + t.Fatalf("max paste content duration = %s, want <= %s", got, maxPasteDuration) + } +} diff --git a/server/service/hid/paste.go b/server/service/hid/paste.go index 3864906..a857aa3 100644 --- a/server/service/hid/paste.go +++ b/server/service/hid/paste.go @@ -23,7 +23,7 @@ type PasteReq struct { const ( defaultPasteDelay = 30 * time.Millisecond - maxPasteDuration = 30 * time.Second + maxPasteDuration = 25 * time.Second maxPasteContentRunes = int(maxPasteDuration / defaultPasteDelay) ) @@ -193,7 +193,7 @@ func (s *Service) Paste(c *gin.Context) { } } if time.Duration(typeableRunes)*defaultPasteDelay > maxPasteDuration { - rsp.ErrRsp(c, -2, "paste duration exceeds 30s") + rsp.ErrRsp(c, -2, "paste duration exceeds 25s") return } diff --git a/server/service/inputcontrol/coordinator.go b/server/service/inputcontrol/coordinator.go index 10f658b..677c0e5 100644 --- a/server/service/inputcontrol/coordinator.go +++ b/server/service/inputcontrol/coordinator.go @@ -429,6 +429,10 @@ func (s *ManualSession) complete(generation uint64, kind ManualReportKind, held case ManualAbsoluteMouse: s.absoluteMouseHeld = held } + } else { + s.keyboardHeld = false + s.relativeMouseHeld = false + s.absoluteMouseHeld = false } releaseControl, end, startCooldown := s.finishIfIdleLocked() s.mu.Unlock() diff --git a/server/service/inputcontrol/coordinator_test.go b/server/service/inputcontrol/coordinator_test.go index ca46d52..16b4046 100644 --- a/server/service/inputcontrol/coordinator_test.go +++ b/server/service/inputcontrol/coordinator_test.go @@ -162,6 +162,49 @@ func TestFailedHeldManualInputDoesNotRemainHeld(t *testing.T) { release() } +func TestFailedReleaseReportClearsHeldManualInput(t *testing.T) { + tests := []struct { + name string + kind ManualReportKind + }{ + {name: "keyboard", kind: ManualKeyboard}, + {name: "relative mouse", kind: ManualRelativeMouse}, + {name: "absolute mouse", kind: ManualAbsoluteMouse}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + now := time.Unix(260, 0) + coordinator := newCoordinator(time.Second, func() time.Time { return now }) + control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP) + manual := NewManualSession(control, coordinator) + defer manual.Close() + + down, err := manual.Reserve(context.Background(), tc.kind, true, nil) + if err != nil { + t.Fatal(err) + } + down.Complete(true) + + up, err := manual.Reserve(context.Background(), tc.kind, false, nil) + if err != nil { + t.Fatal(err) + } + up.Complete(false) + + if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) { + t.Fatalf("cooldown error = %v, want %v", err, ErrManualControlActive) + } + now = now.Add(2 * time.Second) + _, release, err := coordinator.BeginMCP(context.Background(), OperationHID) + if err != nil { + t.Fatalf("failed release report left held input active: %v", err) + } + release() + }) + } +} + func TestPointerMoveWithoutButtonsDoesNotStartCooldown(t *testing.T) { coordinator := newCoordinator(time.Second, time.Now) control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP) @@ -327,6 +370,40 @@ func TestBlockedActiveSessionStillAllowsReleaseReport(t *testing.T) { up.Complete(true) } +func TestBlockedActiveSessionFailedReleaseClearsHeldReport(t *testing.T) { + now := time.Unix(325, 0) + coordinator := newCoordinator(time.Second, func() time.Time { return now }) + control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModePicoclaw) + manual := NewManualSession(control, coordinator) + defer manual.Close() + + down, err := manual.Reserve(context.Background(), ManualKeyboard, true, func(controlmode.Mode) bool { return true }) + if err != nil { + t.Fatal(err) + } + down.Complete(true) + + if _, err := manual.Reserve(context.Background(), ManualKeyboard, true, func(controlmode.Mode) bool { return false }); !errors.Is(err, ErrManualInputBlocked) { + t.Fatalf("new held report error = %v, want %v", err, ErrManualInputBlocked) + } + + up, err := manual.Reserve(context.Background(), ManualKeyboard, false, func(controlmode.Mode) bool { return false }) + if err != nil { + t.Fatalf("release report was blocked: %v", err) + } + up.Complete(false) + + if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) { + t.Fatalf("cooldown error = %v, want %v", err, ErrManualControlActive) + } + now = now.Add(2 * time.Second) + _, release, err := coordinator.BeginMCP(context.Background(), OperationHID) + if err != nil { + t.Fatalf("failed blocked release report left held input active: %v", err) + } + release() +} + func TestReadOnlyMCPAllowedDuringManualControl(t *testing.T) { coordinator := newCoordinator(defaultManualCooldown, time.Now) control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP) diff --git a/server/service/picoclaw/control_operations.go b/server/service/picoclaw/control_operations.go index 43bd025..d4de19a 100644 --- a/server/service/picoclaw/control_operations.go +++ b/server/service/picoclaw/control_operations.go @@ -104,6 +104,17 @@ func (s *Service) beginControlOperation(parent context.Context) (context.Context } } +func (s *Service) beginRuntimeLifecycleOperation(parent context.Context) (context.Context, func()) { + if parent == nil { + parent = context.Background() + } + if s == nil || s.operations == nil { + ctx, cancel := context.WithCancelCause(parent) + return ctx, func() { cancel(context.Canceled) } + } + return s.operations.begin(parent) +} + func (s *Service) CancelActiveControlOperations() int { if s == nil || s.operations == nil { return 0 @@ -115,6 +126,22 @@ func (s *Service) CancelActiveControlOperations() int { return count } +func runtimeLifecycleOperationError(ctx context.Context) *PicoclawError { + if ctx == nil || ctx.Err() == nil { + return nil + } + + cause := context.Cause(ctx) + switch { + case errors.Is(cause, errControlModeSwitch): + return newPicoclawError(CodeControlModeConflict, "PicoClaw runtime lifecycle canceled because the control mode is switching") + case errors.Is(cause, context.DeadlineExceeded): + return newPicoclawError(CodeRuntimeUnavailable, "PicoClaw runtime lifecycle timed out") + default: + return newPicoclawError(CodeRuntimeUnavailable, "PicoClaw runtime lifecycle was canceled") + } +} + func controlOperationError(ctx context.Context) *PicoclawError { if ctx == nil || ctx.Err() == nil { return nil diff --git a/server/service/picoclaw/control_operations_test.go b/server/service/picoclaw/control_operations_test.go index 9fe417f..0648789 100644 --- a/server/service/picoclaw/control_operations_test.go +++ b/server/service/picoclaw/control_operations_test.go @@ -67,6 +67,70 @@ func TestControlModeSwitchCancelsActiveWait(t *testing.T) { } } +func TestControlModeSwitchCancelsRuntimeLifecycle(t *testing.T) { + control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModePicoclaw) + service := &Service{ + control: control, + operations: newControlOperationTracker(), + } + + operationCtx, releaseOperation := service.beginRuntimeLifecycleOperation(context.Background()) + releaseMode, modeErr := service.acquireControlMode() + if modeErr != nil { + t.Fatal(modeErr) + } + + lifecycleDone := make(chan *PicoclawError, 1) + go func() { + <-operationCtx.Done() + lifecycleErr := runtimeLifecycleOperationError(operationCtx) + releaseOperation() + releaseMode() + lifecycleDone <- lifecycleErr + }() + + switchDone := make(chan error, 1) + go func() { + switchDone <- control.Switch(controlmode.ModeMCP, func() error { + service.CancelActiveControlOperations() + return nil + }) + }() + + select { + case err := <-switchDone: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("control mode switch did not cancel the runtime lifecycle operation") + } + + select { + case lifecycleErr := <-lifecycleDone: + if lifecycleErr == nil || lifecycleErr.Code != CodeControlModeConflict { + t.Fatalf("lifecycle error = %+v, want %s", lifecycleErr, CodeControlModeConflict) + } + case <-time.After(time.Second): + t.Fatal("canceled runtime lifecycle operation did not return") + } + + if got := control.Current(); got != controlmode.ModeMCP { + t.Fatalf("mode = %q, want %q", got, controlmode.ModeMCP) + } +} + +func TestRuntimeReadyWaitHonorsLifecycleCancellation(t *testing.T) { + service := &Service{} + ctx, cancel := context.WithCancelCause(context.Background()) + cancel(errControlModeSwitch) + + err := service.waitForRuntimeReadyContext(ctx, time.Hour) + if err == nil || err.Code != CodeControlModeConflict { + t.Fatalf("error = %+v, want %s", err, CodeControlModeConflict) + } +} + func TestWaitDurationIsBounded(t *testing.T) { service := &Service{} _, err := service.executeAction(context.Background(), Action{ diff --git a/server/service/picoclaw/runtime_handlers.go b/server/service/picoclaw/runtime_handlers.go index 2ccb0ee..c27dd81 100644 --- a/server/service/picoclaw/runtime_handlers.go +++ b/server/service/picoclaw/runtime_handlers.go @@ -35,16 +35,24 @@ func (s *Service) StartRuntime(c *gin.Context) { } defer releaseControl() + operationCtx, releaseOperation := s.beginRuntimeLifecycleOperation(c.Request.Context()) + defer releaseOperation() + unlockLifecycle := s.lockRuntimeLifecycle() defer unlockLifecycle() + if lifecycleErr := runtimeLifecycleOperationError(operationCtx); lifecycleErr != nil { + writePicoclawErrorWithData(c, lifecycleErr, gin.H{"status": s.runtimeStatus()}) + return + } + if currentStatus := s.runtime.Get(); currentStatus.Installing { runtimeErr := newPicoclawError(CodeRuntimeUnavailable, "picoclaw installation is in progress") writePicoclawErrorWithData(c, runtimeErr, gin.H{"status": s.runtimeStatus()}) return } - if readyErr := s.ensureRuntimeReadyForLifecycle(); readyErr == nil { + if readyErr := s.ensureRuntimeReadyForLifecycleContext(operationCtx); readyErr == nil { s.setRuntimeIntentDesired(true, "web") status := s.runtimeStatus() log.WithFields(log.Fields{ @@ -67,8 +75,9 @@ func (s *Service) StartRuntime(c *gin.Context) { status.CheckedAt = time.Now() }) - command, output, startErr := s.startRuntime() + command, output, startErr := s.startRuntimeContext(operationCtx) if startErr != nil { + s.markRuntimeLifecycleCanceled(startErr) s.setRuntimeIntentError(startErr.Message) status := s.runtimeStatus() log.WithFields(log.Fields{ diff --git a/server/service/picoclaw/runtime_start_stop.go b/server/service/picoclaw/runtime_start_stop.go index c1f547e..30c6c95 100644 --- a/server/service/picoclaw/runtime_start_stop.go +++ b/server/service/picoclaw/runtime_start_stop.go @@ -14,9 +14,19 @@ import ( ) func (s *Service) startRuntime() (string, string, *PicoclawError) { + return s.startRuntimeContext(context.Background()) +} + +func (s *Service) startRuntimeContext(ctx context.Context) (string, string, *PicoclawError) { + if ctx == nil { + ctx = context.Background() + } if s == nil { return "", "", newPicoclawError(CodeRuntimeStartFailed, "picoclaw service is unavailable") } + if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil { + return "", "", lifecycleErr + } s.ensureDependencies() if installed, statErr := isPicoclawInstalled(); statErr != nil { @@ -77,12 +87,15 @@ func (s *Service) startRuntime() (string, string, *PicoclawError) { } command := scriptPath + " start" - ctx, cancel := context.WithTimeout(context.Background(), picoclawStartTimeout) + startCtx, cancel := context.WithTimeout(ctx, picoclawStartTimeout) defer cancel() - output, execErr := exec.CommandContext(ctx, "sh", "-c", command).CombinedOutput() + output, execErr := exec.CommandContext(startCtx, "sh", "-c", command).CombinedOutput() trimmedOutput := strings.TrimSpace(string(output)) if execErr != nil { + if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil { + return command, trimmedOutput, lifecycleErr + } s.runtime.Update(func(status *RuntimeStatus) { status.Ready = false status.Installed = true @@ -96,17 +109,24 @@ func (s *Service) startRuntime() (string, string, *PicoclawError) { return command, trimmedOutput, newPicoclawError(CodeRuntimeStartFailed, "failed to start picoclaw runtime") } - time.Sleep(picoclawStartWaitPeriod) - if runtimeErr := s.waitForRuntimeReady(picoclawStartTimeout); runtimeErr != nil { + if lifecycleErr := sleepRuntimeLifecycleContext(ctx, picoclawStartWaitPeriod); lifecycleErr != nil { + return command, trimmedOutput, lifecycleErr + } + if runtimeErr := s.waitForRuntimeReadyContext(ctx, picoclawStartTimeout); runtimeErr != nil { startErr := newPicoclawError(CodeRuntimeStartFailed, runtimeErr.Message) failureStatus := "unavailable" - if cleanupErr := s.stopRuntimeAndVerify(true); cleanupErr != nil { - failureStatus = "error" - startErr.Message = fmt.Sprintf( - "%s; failed to stop partially started runtime: %v", - startErr.Message, - cleanupErr, - ) + if runtimeErr.Code == CodeControlModeConflict { + startErr = runtimeErr + failureStatus = "starting" + } else { + if cleanupErr := s.stopRuntimeAndVerify(true); cleanupErr != nil { + failureStatus = "error" + startErr.Message = fmt.Sprintf( + "%s; failed to stop partially started runtime: %v", + startErr.Message, + cleanupErr, + ) + } } s.runtime.Update(func(status *RuntimeStatus) { status.Ready = false @@ -121,6 +141,23 @@ func (s *Service) startRuntime() (string, string, *PicoclawError) { return command, trimmedOutput, nil } +func (s *Service) markRuntimeLifecycleCanceled(err *PicoclawError) { + if s == nil || err == nil || err.Code != CodeControlModeConflict { + return + } + s.ensureDependencies() + s.runtime.Update(func(status *RuntimeStatus) { + status.Ready = false + status.Restoring = false + if isRuntimeLifecycleStatusPending(*status) { + status.Status = "stopped" + } + status.LastError = err.Message + status.CurrentSession = "" + status.CheckedAt = time.Now() + }) +} + func (s *Service) stopRuntime() (string, string, *PicoclawError) { if s == nil { return "", "", newPicoclawError(CodeRuntimeStartFailed, "picoclaw service is unavailable") @@ -183,11 +220,22 @@ func (s *Service) stopRuntime() (string, string, *PicoclawError) { } func (s *Service) waitForRuntimeReady(timeout time.Duration) *PicoclawError { + return s.waitForRuntimeReadyContext(context.Background(), timeout) +} + +func (s *Service) waitForRuntimeReadyContext(ctx context.Context, timeout time.Duration) *PicoclawError { + if ctx == nil { + ctx = context.Background() + } deadline := time.Now().Add(timeout) var lastErr *PicoclawError for { - runtimeErr := s.ensureRuntimeReadyForLifecycle() + if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil { + return lifecycleErr + } + + runtimeErr := s.ensureRuntimeReadyForLifecycleContext(ctx) if runtimeErr == nil { return nil } else { @@ -198,7 +246,9 @@ func (s *Service) waitForRuntimeReady(timeout time.Duration) *PicoclawError { break } - time.Sleep(picoclawReadyPollPeriod) + if lifecycleErr := sleepRuntimeLifecycleContext(ctx, picoclawReadyPollPeriod); lifecycleErr != nil { + return lifecycleErr + } } if lastErr != nil { @@ -218,18 +268,31 @@ func resolvePicoclawStartScript() (string, error) { } func runPicoclawOnboard() (string, *PicoclawError) { + return runPicoclawOnboardContext(context.Background()) +} + +func runPicoclawOnboardContext(ctx context.Context) (string, *PicoclawError) { + if ctx == nil { + ctx = context.Background() + } scriptPath, err := resolvePicoclawStartScript() if err != nil { return "", newPicoclawError(CodeRuntimeUnavailable, err.Error()) } + if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil { + return "", lifecycleErr + } command := scriptPath + " onboard" - ctx, cancel := context.WithTimeout(context.Background(), picoclawOnboardTimeout) + onboardCtx, cancel := context.WithTimeout(ctx, picoclawOnboardTimeout) defer cancel() - output, execErr := exec.CommandContext(ctx, "sh", "-c", command).CombinedOutput() + output, execErr := exec.CommandContext(onboardCtx, "sh", "-c", command).CombinedOutput() trimmedOutput := strings.TrimSpace(string(output)) if execErr != nil { + if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil { + return trimmedOutput, lifecycleErr + } if trimmedOutput == "" { trimmedOutput = execErr.Error() } @@ -239,6 +302,23 @@ func runPicoclawOnboard() (string, *PicoclawError) { return trimmedOutput, nil } +func sleepRuntimeLifecycleContext(ctx context.Context, delay time.Duration) *PicoclawError { + if ctx == nil { + ctx = context.Background() + } + if delay <= 0 { + return runtimeLifecycleOperationError(ctx) + } + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return runtimeLifecycleOperationError(ctx) + case <-timer.C: + return nil + } +} + func isPicoclawInstalled() (bool, error) { info, err := os.Stat(picoclawBinaryPath) if err == nil { diff --git a/server/service/picoclaw/service.go b/server/service/picoclaw/service.go index 37e5e52..53496e7 100644 --- a/server/service/picoclaw/service.go +++ b/server/service/picoclaw/service.go @@ -1,6 +1,7 @@ package picoclaw import ( + "context" "os" "sync" "time" @@ -355,17 +356,24 @@ func (s *Service) lockRuntimeLifecycle() func() { } func (s *Service) ensureRuntimeReady() *PicoclawError { - return s.ensureRuntimeReadyWithProbeProtection(false) + return s.ensureRuntimeReadyWithProbeProtection(context.Background(), false) } func (s *Service) ensureRuntimeReadyForLifecycle() *PicoclawError { - return s.ensureRuntimeReadyWithProbeProtection(true) + return s.ensureRuntimeReadyForLifecycleContext(context.Background()) } -func (s *Service) ensureRuntimeReadyWithProbeProtection(allowLifecycleOverwrite bool) *PicoclawError { +func (s *Service) ensureRuntimeReadyForLifecycleContext(ctx context.Context) *PicoclawError { + return s.ensureRuntimeReadyWithProbeProtection(ctx, true) +} + +func (s *Service) ensureRuntimeReadyWithProbeProtection(ctx context.Context, allowLifecycleOverwrite bool) *PicoclawError { if s == nil { return newPicoclawError(CodeRuntimeUnavailable, "picoclaw service is unavailable") } + if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil { + return lifecycleErr + } s.ensureDependencies() setStatus := func(status RuntimeStatus) { if allowLifecycleOverwrite { @@ -440,7 +448,10 @@ func (s *Service) ensureRuntimeReadyWithProbeProtection(allowLifecycleOverwrite } if _, err := os.Stat(configPath); err != nil { if os.IsNotExist(err) { - if _, onboardErr := runPicoclawOnboard(); onboardErr != nil { + if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil { + return lifecycleErr + } + if _, onboardErr := runPicoclawOnboardContext(ctx); onboardErr != nil { setStatus(RuntimeStatus{ Ready: false, Installed: true, @@ -455,6 +466,9 @@ func (s *Service) ensureRuntimeReadyWithProbeProtection(allowLifecycleOverwrite }) return newPicoclawError(CodeRuntimeUnavailable, "picoclaw model is not configured") } + if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil { + return lifecycleErr + } if _, statErr := os.Stat(configPath); statErr == nil { goto configReady } diff --git a/web/src/i18n/locales/ca.ts b/web/src/i18n/locales/ca.ts index f79b4de..aa93997 100644 --- a/web/src/i18n/locales/ca.ts +++ b/web/src/i18n/locales/ca.ts @@ -290,6 +290,7 @@ const ca = { enableConfirmDesc: 'En activar MCP, PicoClaw s’aturarà i es tancarà qualsevol sessió activa de PicoClaw.', failed: 'L’operació MCP ha fallat', + copyFailed: 'La còpia ha fallat. Copieu-ho manualment.', okBtn: 'Confirma', cancelBtn: 'Cancel·la' }, @@ -599,7 +600,6 @@ const ca = { success: 'PicoClaw instal·lat correctament', failed: "No s'ha pogut instal·lar PicoClaw", uninstalling: "S'està desinstal·lant el temps d'execució...", - uninstaling: "S'està desinstal·lant el temps d'execució...", uninstalled: "El temps d'execució s'ha desinstal·lat correctament.", uninstallFailed: 'La desinstal·lació ha fallat.', requiredTitle: 'PicoClaw no està instal·lat', diff --git a/web/src/i18n/locales/cz.ts b/web/src/i18n/locales/cz.ts index 98c41c5..fc7eb4e 100644 --- a/web/src/i18n/locales/cz.ts +++ b/web/src/i18n/locales/cz.ts @@ -293,6 +293,7 @@ const cz = { enableConfirmDesc: 'Povolením MCP se zastaví PicoClaw a ukončí se všechny aktivní relace PicoClaw.', failed: 'Operace MCP se nezdařila', + copyFailed: 'Kopírování se nezdařilo. Zkopírujte ručně.', okBtn: 'Potvrdit', cancelBtn: 'Zrušit' }, diff --git a/web/src/i18n/locales/da.ts b/web/src/i18n/locales/da.ts index 1ec0017..b24cf2d 100644 --- a/web/src/i18n/locales/da.ts +++ b/web/src/i18n/locales/da.ts @@ -290,6 +290,7 @@ const da = { enableConfirmDesc: 'Aktivering af MCP stopper PicoClaw og lukker alle aktive PicoClaw-sessioner.', failed: 'MCP-handlingen mislykkedes', + copyFailed: 'Kopiering mislykkedes. Kopiér manuelt.', okBtn: 'Bekræft', cancelBtn: 'Annuller' }, diff --git a/web/src/i18n/locales/de.ts b/web/src/i18n/locales/de.ts index ca9c3b5..4759fd0 100644 --- a/web/src/i18n/locales/de.ts +++ b/web/src/i18n/locales/de.ts @@ -296,6 +296,7 @@ const de = { enableConfirmDesc: 'Durch Aktivieren von MCP wird PicoClaw gestoppt und jede aktive PicoClaw-Sitzung geschlossen.', failed: 'MCP-Aktion fehlgeschlagen', + copyFailed: 'Kopieren fehlgeschlagen. Bitte manuell kopieren.', okBtn: 'Bestätigen', cancelBtn: 'Abbrechen' }, diff --git a/web/src/i18n/locales/es.ts b/web/src/i18n/locales/es.ts index 02caf2e..782e8e7 100644 --- a/web/src/i18n/locales/es.ts +++ b/web/src/i18n/locales/es.ts @@ -293,6 +293,7 @@ const es = { enableConfirmDesc: 'Al activar MCP se detendrá PicoClaw y se cerrará cualquier sesión activa de PicoClaw.', failed: 'Error en la operación MCP', + copyFailed: 'Error al copiar. Copia manualmente.', okBtn: 'Confirmar', cancelBtn: 'Cancelar' }, diff --git a/web/src/i18n/locales/fr.ts b/web/src/i18n/locales/fr.ts index dcd56af..424969b 100644 --- a/web/src/i18n/locales/fr.ts +++ b/web/src/i18n/locales/fr.ts @@ -295,6 +295,7 @@ const fr = { enableConfirmDesc: 'L’activation de MCP arrêtera PicoClaw et fermera toute session PicoClaw active.', failed: 'Échec de l’opération MCP', + copyFailed: 'La copie a échoué. Copiez manuellement.', okBtn: 'Confirmer', cancelBtn: 'Annuler' }, diff --git a/web/src/i18n/locales/hu.ts b/web/src/i18n/locales/hu.ts index 26b3c49..f07977a 100644 --- a/web/src/i18n/locales/hu.ts +++ b/web/src/i18n/locales/hu.ts @@ -294,6 +294,7 @@ const hu = { enableConfirmDesc: 'Az MCP engedélyezése leállítja a PicoClaw-t, és bezár minden aktív PicoClaw-munkamenetet.', failed: 'Az MCP-művelet sikertelen', + copyFailed: 'A másolás sikertelen. Másolja kézzel.', okBtn: 'Megerősítés', cancelBtn: 'Mégse' }, diff --git a/web/src/i18n/locales/id.ts b/web/src/i18n/locales/id.ts index 777e243..1e56fc3 100644 --- a/web/src/i18n/locales/id.ts +++ b/web/src/i18n/locales/id.ts @@ -292,6 +292,7 @@ const id = { enableConfirmDesc: 'Mengaktifkan MCP akan menghentikan PicoClaw dan menutup semua sesi PicoClaw yang aktif.', failed: 'Operasi MCP gagal', + copyFailed: 'Gagal menyalin. Salin secara manual.', okBtn: 'Konfirmasi', cancelBtn: 'Batal' }, diff --git a/web/src/i18n/locales/it.ts b/web/src/i18n/locales/it.ts index a5b073b..75c49d1 100644 --- a/web/src/i18n/locales/it.ts +++ b/web/src/i18n/locales/it.ts @@ -294,6 +294,7 @@ const it = { enableConfirmDesc: 'L’abilitazione di MCP arresterà PicoClaw e chiuderà tutte le sessioni PicoClaw attive.', failed: 'Operazione MCP non riuscita', + copyFailed: 'Copia non riuscita. Copia manualmente.', okBtn: 'Conferma', cancelBtn: 'Annulla' }, diff --git a/web/src/i18n/locales/ja.ts b/web/src/i18n/locales/ja.ts index b028312..8c010d7 100644 --- a/web/src/i18n/locales/ja.ts +++ b/web/src/i18n/locales/ja.ts @@ -293,6 +293,7 @@ const ja = { enableConfirmDesc: 'MCP を有効にすると PicoClaw が停止し、アクティブな PicoClaw セッションがすべて終了します。', failed: 'MCP 操作に失敗しました', + copyFailed: 'コピーに失敗しました。手動でコピーしてください。', okBtn: '確認', cancelBtn: 'キャンセル' }, diff --git a/web/src/i18n/locales/ko.ts b/web/src/i18n/locales/ko.ts index 4f94f0b..901016c 100644 --- a/web/src/i18n/locales/ko.ts +++ b/web/src/i18n/locales/ko.ts @@ -288,6 +288,7 @@ const ko = { enableConfirmDesc: 'MCP를 활성화하면 PicoClaw가 중지되고 활성 PicoClaw 세션이 모두 닫힙니다.', failed: 'MCP 작업에 실패했습니다', + copyFailed: '복사에 실패했습니다. 수동으로 복사하세요.', okBtn: '확인', cancelBtn: '취소' }, diff --git a/web/src/i18n/locales/nb.ts b/web/src/i18n/locales/nb.ts index 78f2ea8..e6fe602 100644 --- a/web/src/i18n/locales/nb.ts +++ b/web/src/i18n/locales/nb.ts @@ -291,6 +291,7 @@ const nb = { enableConfirmDesc: 'Aktivering av MCP stopper PicoClaw og lukker alle aktive PicoClaw-økter.', failed: 'MCP-operasjonen mislyktes', + copyFailed: 'Kopiering mislyktes. Kopier manuelt.', okBtn: 'Bekreft', cancelBtn: 'Avbryt' }, diff --git a/web/src/i18n/locales/nl.ts b/web/src/i18n/locales/nl.ts index a93aabc..e996539 100644 --- a/web/src/i18n/locales/nl.ts +++ b/web/src/i18n/locales/nl.ts @@ -294,6 +294,7 @@ const nl = { enableConfirmDesc: 'Als MCP wordt ingeschakeld, stopt PicoClaw en worden alle actieve PicoClaw-sessies gesloten.', failed: 'MCP-bewerking mislukt', + copyFailed: 'Kopiëren mislukt. Kopieer handmatig.', okBtn: 'Bevestigen', cancelBtn: 'Annuleren' }, diff --git a/web/src/i18n/locales/pl.ts b/web/src/i18n/locales/pl.ts index 49f847d..08f24d2 100644 --- a/web/src/i18n/locales/pl.ts +++ b/web/src/i18n/locales/pl.ts @@ -293,6 +293,7 @@ const pl = { enableConfirmDesc: 'Włączenie MCP zatrzyma PicoClaw i zamknie wszystkie aktywne sesje PicoClaw.', failed: 'Operacja MCP nie powiodła się', + copyFailed: 'Kopiowanie nie powiodło się. Skopiuj ręcznie.', okBtn: 'Potwierdź', cancelBtn: 'Anuluj' }, diff --git a/web/src/i18n/locales/pt_br.ts b/web/src/i18n/locales/pt_br.ts index 38b9642..4fcc133 100644 --- a/web/src/i18n/locales/pt_br.ts +++ b/web/src/i18n/locales/pt_br.ts @@ -292,6 +292,7 @@ const pt_br = { enableConfirmDesc: 'Habilitar o MCP interromperá o PicoClaw e fechará todas as sessões ativas do PicoClaw.', failed: 'Falha na operação MCP', + copyFailed: 'Falha ao copiar. Copie manualmente.', okBtn: 'Confirmar', cancelBtn: 'Cancelar' }, diff --git a/web/src/i18n/locales/ru.ts b/web/src/i18n/locales/ru.ts index 6fbdf1a..8802a2d 100644 --- a/web/src/i18n/locales/ru.ts +++ b/web/src/i18n/locales/ru.ts @@ -292,6 +292,7 @@ const ru = { enableConfirmDesc: 'Включение MCP остановит PicoClaw и закроет все активные сеансы PicoClaw.', failed: 'Операция MCP завершилась с ошибкой', + copyFailed: 'Не удалось скопировать. Скопируйте вручную.', okBtn: 'Подтвердить', cancelBtn: 'Отмена' }, diff --git a/web/src/i18n/locales/se.ts b/web/src/i18n/locales/se.ts index 1c84acb..24ba881 100644 --- a/web/src/i18n/locales/se.ts +++ b/web/src/i18n/locales/se.ts @@ -289,6 +289,7 @@ const se = { enableConfirmDesc: 'Om MCP aktiveras stoppas PicoClaw och alla aktiva PicoClaw-sessioner stängs.', failed: 'MCP-åtgärden misslyckades', + copyFailed: 'Kopiering misslyckades. Kopiera manuellt.', okBtn: 'Bekräfta', cancelBtn: 'Avbryt' }, diff --git a/web/src/i18n/locales/th.ts b/web/src/i18n/locales/th.ts index 61bc1af..5bcf672 100644 --- a/web/src/i18n/locales/th.ts +++ b/web/src/i18n/locales/th.ts @@ -285,6 +285,7 @@ const th = { enableConfirmDesc: 'การเปิดใช้งาน MCP จะหยุด PicoClaw และปิดเซสชัน PicoClaw ที่ใช้งานอยู่ทั้งหมด', failed: 'การดำเนินการ MCP ล้มเหลว', + copyFailed: 'คัดลอกไม่สำเร็จ โปรดคัดลอกด้วยตนเอง', okBtn: 'ยืนยัน', cancelBtn: 'ยกเลิก' }, diff --git a/web/src/i18n/locales/tr.ts b/web/src/i18n/locales/tr.ts index 21bfa73..f36f78a 100644 --- a/web/src/i18n/locales/tr.ts +++ b/web/src/i18n/locales/tr.ts @@ -291,6 +291,7 @@ const tr = { enableConfirmDesc: 'MCP etkinleştirildiğinde PicoClaw durdurulur ve tüm etkin PicoClaw oturumları kapatılır.', failed: 'MCP işlemi başarısız oldu', + copyFailed: 'Kopyalama başarısız. Elle kopyalayın.', okBtn: 'Onayla', cancelBtn: 'İptal' }, diff --git a/web/src/i18n/locales/uk.ts b/web/src/i18n/locales/uk.ts index 9a01bf1..708b19c 100644 --- a/web/src/i18n/locales/uk.ts +++ b/web/src/i18n/locales/uk.ts @@ -292,6 +292,7 @@ const uk = { enableConfirmTitle: 'Увімкнути зовнішнє керування MCP?', enableConfirmDesc: 'Увімкнення MCP зупинить PicoClaw і закриє всі активні сеанси PicoClaw.', failed: 'Операція MCP завершилася помилкою', + copyFailed: 'Не вдалося скопіювати. Скопіюйте вручну.', okBtn: 'Підтвердити', cancelBtn: 'Скасувати' }, diff --git a/web/src/i18n/locales/vi.ts b/web/src/i18n/locales/vi.ts index 63fde92..84b2a50 100644 --- a/web/src/i18n/locales/vi.ts +++ b/web/src/i18n/locales/vi.ts @@ -289,6 +289,7 @@ const vi = { enableConfirmTitle: 'Bật điều khiển MCP bên ngoài?', enableConfirmDesc: 'Bật MCP sẽ dừng PicoClaw và đóng tất cả phiên PicoClaw đang hoạt động.', failed: 'Thao tác MCP không thành công', + copyFailed: 'Sao chép thất bại. Vui lòng sao chép thủ công.', okBtn: 'Xác nhận', cancelBtn: 'Hủy' }, diff --git a/web/src/pages/desktop/picoclaw/sidebar-effects.ts b/web/src/pages/desktop/picoclaw/sidebar-effects.ts index a78c958..772c012 100644 --- a/web/src/pages/desktop/picoclaw/sidebar-effects.ts +++ b/web/src/pages/desktop/picoclaw/sidebar-effects.ts @@ -379,6 +379,12 @@ export function usePicoclawSidebarLifecycle({ setTransportState, setRunState }: LifecycleOptions) { + const tRef = useRef(t); + + useEffect(() => { + tRef.current = t; + }, [t]); + useEffect(() => { let cancelled = false; @@ -396,7 +402,7 @@ export function usePicoclawSidebarLifecycle({ if (nextRuntimeStatus?.installing) { setMessages((current) => [ ...current, - createStatusMessage(t('picoclaw.install.installing')) + createStatusMessage(tRef.current('picoclaw.install.installing')) ]); return; } @@ -404,7 +410,7 @@ export function usePicoclawSidebarLifecycle({ if (nextRuntimeStatus?.installed === false) { setMessages((current) => [ ...current, - createStatusMessage(t('picoclaw.install.requiredDescription')) + createStatusMessage(tRef.current('picoclaw.install.requiredDescription')) ]); return; } @@ -412,7 +418,7 @@ export function usePicoclawSidebarLifecycle({ if (nextRuntimeStatus?.model_configured === false) { setMessages((current) => [ ...current, - createStatusMessage(t('picoclaw.model.requiredDescription')) + createStatusMessage(tRef.current('picoclaw.model.requiredDescription')) ]); return; } @@ -423,7 +429,7 @@ export function usePicoclawSidebarLifecycle({ setMessages((current) => [ ...current, - createStatusMessage(t('picoclaw.status.connecting')) + createStatusMessage(tRef.current('picoclaw.status.connecting')) ]); try { await connectGateway(nextSessionId); @@ -460,8 +466,7 @@ export function usePicoclawSidebarLifecycle({ setOverlay, setRunState, setTakeover, - setTransportState, - t + setTransportState ]); }