app: add desktop onboarding flow (#17853)

This commit is contained in:
Eva H
2026-08-19 15:40:16 -07:00
committed by GitHub
parent e0c95a5ffd
commit b7871fc0d1
37 changed files with 1404 additions and 466 deletions
+61 -23
View File
@@ -146,15 +146,10 @@ func main() {
// Do this after logging is set up so we can debug issues
if runtime.GOOS == "windows" && urlSchemeRequest != "" {
slog.Debug("checking for existing instance", "url", urlSchemeRequest)
if checkAndHandleExistingInstance(urlSchemeRequest) {
// The function will exit if it successfully sends to another instance
// If we reach here, we're the first/only instance
} else {
// No existing instance found, handle the URL scheme in this instance
go func() {
handleURLSchemeInCurrentInstance(urlSchemeRequest)
}()
}
// This exits after forwarding the request when another instance is
// running. First-instance requests are handled later by osRun, after the
// Windows UI dependencies are initialized and from the primary thread.
checkAndHandleExistingInstance(urlSchemeRequest)
}
// Detect if this is a first start after an upgrade, in
@@ -205,6 +200,12 @@ func main() {
uiServerPort = port
st := &store.Store{}
if devMode {
if dbPath := strings.TrimSpace(os.Getenv("OLLAMA_APP_DB_PATH")); dbPath != "" {
st.DBPath = dbPath
slog.Debug("using development app database", "path", dbPath)
}
}
appStore = st
// Enable CORS in development mode
@@ -324,11 +325,11 @@ func main() {
quit()
}()
if urlSchemeRequest != "" {
if urlSchemeRequest != "" && runtime.GOOS != "windows" {
go func() {
handleURLSchemeInCurrentInstance(urlSchemeRequest)
}()
} else {
} else if urlSchemeRequest == "" {
slog.Debug("no URL scheme request to handle")
}
@@ -343,7 +344,13 @@ func main() {
}
}()
osRun(cancel, hasCompletedFirstRun, startHidden)
settings, settingsErr := st.Settings()
showOnboarding := shouldShowOnboarding(settings, settingsErr)
if settingsErr != nil {
slog.Error("failed to load onboarding state", "error", settingsErr)
}
osRun(cancel, hasCompletedFirstRun, startHidden, showOnboarding, urlSchemeRequest)
slog.Info("shutting down desktop server")
if err := srv.Close(); err != nil {
@@ -355,6 +362,31 @@ func main() {
<-done
}
func shouldShowOnboarding(settings store.Settings, err error) bool {
return err != nil || settings.OnboardingVersion < store.CurrentOnboardingVersion
}
func runInitialWindowsUI(
startHidden bool,
showOnboarding bool,
urlSchemeRequest string,
startHiddenFn func(),
handleURLFn func(string),
showOnboardingFn func(),
) {
if urlSchemeRequest != "" {
handleURLFn(urlSchemeRequest)
return
}
if startHidden {
startHiddenFn()
return
}
if showOnboarding {
showOnboardingFn()
}
}
func startHiddenTasks() {
// If an upgrade is ready and we're in hidden mode, perform it at startup.
// If we're not in hidden mode, we want to start as fast as possible and not
@@ -432,7 +464,7 @@ func checkUserLoggedIn(uiServerPort int) bool {
func handleConnectURLScheme() {
if checkUserLoggedIn(uiServerPort) {
slog.Info("user is already logged in, opening app instead")
showWindow(wv.webview.Window())
openUI("/")
return
}
@@ -491,17 +523,23 @@ func parseURLScheme(urlSchemeRequest string) (isConnect bool, err error) {
// handleURLSchemeInCurrentInstance processes URL scheme requests in the current instance
func handleURLSchemeInCurrentInstance(urlSchemeRequest string) {
isConnect, err := parseURLScheme(urlSchemeRequest)
err := dispatchURLSchemeRequest(urlSchemeRequest, handleConnectURLScheme, func() {
openUI("/")
})
if err != nil {
slog.Error("failed to parse URL scheme request", "url", urlSchemeRequest, "error", err)
return
}
if isConnect {
handleConnectURLScheme()
} else {
if wv.webview != nil {
showWindow(wv.webview.Window())
}
}
}
func dispatchURLSchemeRequest(urlSchemeRequest string, connect, open func()) error {
isConnect, err := parseURLScheme(urlSchemeRequest)
if err != nil {
return err
}
if isConnect {
connect()
} else {
open()
}
return nil
}
+16 -10
View File
@@ -54,14 +54,17 @@ func StartUI(path *C.cchar_t) {
//export ShowUI
func ShowUI() {
// If webview is already running, just show the window
openUI("/")
}
func openUI(path string) {
if wv.IsRunning() && wv.webview != nil {
showWindow(wv.webview.Window())
} else {
root := C.CString("/")
defer C.free(unsafe.Pointer(root))
StartUI(root)
return
}
p := C.CString(path)
defer C.free(unsafe.Pointer(p))
StartUI(p)
}
//export StopUI
@@ -172,13 +175,13 @@ func UpdateAvailable(ver string) error {
return nil
}
func osRun(_ func(), hasCompletedFirstRun, startHidden bool) {
func osRun(_ func(), hasCompletedFirstRun, startHidden, showOnboarding bool, _ string) {
registerLaunchAgent(hasCompletedFirstRun)
// Run the native macOS app
// Note: this will block until the app is closed
slog.Debug("starting native darwin event loop")
C.run(C._Bool(hasCompletedFirstRun), C._Bool(startHidden))
C.run(C._Bool(showOnboarding), C._Bool(startHidden))
}
func quit() {
@@ -229,6 +232,11 @@ func styleWindow(ptr unsafe.Pointer) {
C.styleWindow(C.uintptr_t(uintptr(ptr)))
}
func setOnboardingWindowStyle(ptr unsafe.Pointer, enabled bool) {
styleWindow(ptr)
C.setWindowResizable(C.uintptr_t(uintptr(ptr)), C.bool(!enabled))
}
func runInBackground() {
cmd := exec.Command(filepath.Join(updater.BundlePath, "Contents", "MacOS", "Ollama"), "hidden")
if cmd != nil {
@@ -257,6 +265,4 @@ func handleConnectURL() {
}
// checkAndHandleExistingInstance is not needed on non-Windows platforms
func checkAndHandleExistingInstance(_ string) bool {
return false
}
func checkAndHandleExistingInstance(_ string) {}
+2 -1
View File
@@ -16,7 +16,7 @@ enum AppMove
MoveError,
};
void run(bool firstTimeRun, bool startHidden);
void run(bool showOnboarding, bool startHidden);
void killOtherInstances();
enum AppMove askToMoveToApplications();
int createSymlinkWithAuthorization();
@@ -38,6 +38,7 @@ void setWindowDelegate(void *window);
void showWindow(uintptr_t wndPtr);
void hideWindow(uintptr_t wndPtr);
void styleWindow(uintptr_t wndPtr);
void setWindowResizable(uintptr_t wndPtr, bool resizable);
void drag(uintptr_t wndPtr);
void doubleClick(uintptr_t wndPtr);
void handleConnectURL();
+47 -35
View File
@@ -19,7 +19,27 @@ extern NSString *SystemWidePath;
@implementation AppDelegate
bool firstTimeRun,startHidden; // Set in run before initialization
bool showOnboarding,startHidden; // Set in run before initialization
static NSBundle *OllamaResourceBundle(void) {
NSBundle *bundle = [NSBundle mainBundle];
if ([bundle.bundlePath hasSuffix:@".app"]) {
return bundle;
}
NSString *cwdPath = [[NSFileManager defaultManager] currentDirectoryPath];
NSArray<NSString *> *bundlePaths = @[
[cwdPath stringByAppendingPathComponent:@"darwin/Ollama.app"],
[cwdPath stringByAppendingPathComponent:@"app/darwin/Ollama.app"],
];
for (NSString *bundlePath in bundlePaths) {
if ([[NSFileManager defaultManager] fileExistsAtPath:bundlePath]) {
return [NSBundle bundleWithPath:bundlePath];
}
}
return bundle;
}
- (void)application:(NSApplication *)application openURLs:(NSArray<NSURL *> *)urls {
for (NSURL *url in urls) {
@@ -30,11 +50,9 @@ bool firstTimeRun,startHidden; // Set in run before initialization
// Special case: handle connect by opening browser instead of app
handleConnectURL();
} else {
// Set app to be active and visible
[NSApp setActivationPolicy:NSApplicationActivationPolicyRegular];
[NSApp activateIgnoringOtherApps:YES];
[self openUI];
}
break;
}
}
@@ -51,29 +69,24 @@ bool firstTimeRun,startHidden; // Set in run before initialization
// if we're in development mode, set the app icon
NSString *bundlePath = [[NSBundle mainBundle] bundlePath];
if (![bundlePath hasSuffix:@".app"]) {
NSString *cwdPath =
[[NSFileManager defaultManager] currentDirectoryPath];
NSString *iconPath = [cwdPath
stringByAppendingPathComponent:
[NSString
stringWithFormat:
@"darwin/Ollama.app/Contents/Resources/icon.icns"]];
NSString *iconPath =
[OllamaResourceBundle() pathForResource:@"icon" ofType:@"icns"];
NSImage *customIcon = [[NSImage alloc] initWithContentsOfFile:iconPath];
[NSApp setApplicationIconImage:customIcon];
}
// Create status item and menu
NSMenu *menu = [[NSMenu alloc] init];
[menu addItemWithTitle:@"Settings..."
action:@selector(settingsUI)
keyEquivalent:@","];
NSMenuItem *openMenuItem =
[[NSMenuItem alloc] initWithTitle:@"Open Ollama"
[[NSMenuItem alloc] initWithTitle:@"Open Ollama Chat"
action:@selector(openUI)
keyEquivalent:@""];
[openMenuItem setTarget:self];
[menu addItem:openMenuItem];
[menu addItemWithTitle:@"Settings..."
action:@selector(settingsUI)
keyEquivalent:@","];
[menu addItem:[NSMenuItem separatorItem]];
NSMenuItem *updateAvailable =
@@ -214,10 +227,8 @@ bool firstTimeRun,startHidden; // Set in run before initialization
dispatch_async(dispatch_get_main_queue(), ^{
if (hidden || startHidden) {
darwinStartHiddenTasks();
} else {
if (!startHidden) {
StartUI("/");
}
} else if (showOnboarding) {
StartUI("/");
}
});
}
@@ -264,7 +275,7 @@ bool firstTimeRun,startHidden; // Set in run before initialization
}
- (void)openUI {
ShowUI();
[self uiRequest:@"/"];
}
- (void)newChat {
@@ -325,17 +336,7 @@ bool firstTimeRun,startHidden; // Set in run before initialization
}
NSImage *statusImage;
NSBundle *bundle = [NSBundle mainBundle];
if (![bundle.bundlePath hasSuffix:@".app"]) {
NSString *cwdPath =
[[NSFileManager defaultManager] currentDirectoryPath];
NSString *bundlePath =
[cwdPath stringByAppendingPathComponent:
[NSString stringWithFormat:@"darwin/Ollama.app"]];
bundle = [NSBundle bundleWithPath:bundlePath];
}
statusImage = [bundle imageForResource:iconName];
statusImage = [OllamaResourceBundle() imageForResource:iconName];
[statusImage setTemplate:YES];
self.statusItem.button.image = statusImage;
}
@@ -621,12 +622,12 @@ decidePolicyForNavigationAction:(WKNavigationAction *)action
@end
AppDelegate *appDelegate;
void run(bool ftr, bool sh) {
void run(bool so, bool sh) {
[NSApplication sharedApplication];
[NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory];
appDelegate = [[AppDelegate alloc] init];
[NSApp setDelegate:appDelegate];
firstTimeRun = ftr;
showOnboarding = so;
startHidden = sh;
[NSApp run];
StopUI();
@@ -1097,6 +1098,17 @@ void styleWindow(uintptr_t wndPtr) {
L.borderWidth = 0.0;
}
void setWindowResizable(uintptr_t wndPtr, bool resizable) {
NSWindow *w = (__bridge NSWindow *)wndPtr;
if (!w) return;
if (resizable) {
w.styleMask |= NSWindowStyleMaskResizable;
} else {
w.styleMask &= ~NSWindowStyleMaskResizable;
}
}
void drag(uintptr_t wndPtr) {
NSWindow *w = (__bridge NSWindow *)wndPtr;
if (!w) return;
+112
View File
@@ -0,0 +1,112 @@
//go:build windows || darwin
package main
import (
"errors"
"testing"
"github.com/ollama/ollama/app/store"
)
func TestShouldShowOnboarding(t *testing.T) {
tests := []struct {
name string
settings store.Settings
err error
want bool
}{
{
name: "fresh install",
settings: store.Settings{OnboardingVersion: 0},
want: true,
},
{
name: "completed onboarding",
settings: store.Settings{OnboardingVersion: store.CurrentOnboardingVersion},
want: false,
},
{
name: "settings failure",
err: errors.New("settings unavailable"),
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := shouldShowOnboarding(tt.settings, tt.err); got != tt.want {
t.Fatalf("shouldShowOnboarding() = %v, want %v", got, tt.want)
}
})
}
}
func TestDispatchURLSchemeRequest(t *testing.T) {
tests := []struct {
name string
request string
wantConnect bool
wantOpen bool
wantErr bool
}{
{name: "bare URL opens app", request: "ollama://", wantOpen: true},
{name: "connect URL starts connection", request: "ollama://connect", wantConnect: true},
{name: "unsupported URL", request: "ollama://unsupported", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
connected := false
opened := false
err := dispatchURLSchemeRequest(
tt.request,
func() { connected = true },
func() { opened = true },
)
if (err != nil) != tt.wantErr {
t.Fatalf("dispatchURLSchemeRequest() error = %v, wantErr %v", err, tt.wantErr)
}
if connected != tt.wantConnect {
t.Errorf("connect called = %v, want %v", connected, tt.wantConnect)
}
if opened != tt.wantOpen {
t.Errorf("open called = %v, want %v", opened, tt.wantOpen)
}
})
}
}
func TestRunInitialWindowsUIWithBareURL(t *testing.T) {
hiddenCalls := 0
urlCalls := 0
onboardingCalls := 0
openCalls := 0
runInitialWindowsUI(
false,
true,
"ollama://",
func() { hiddenCalls++ },
func(request string) {
urlCalls++
if err := dispatchURLSchemeRequest(request, func() {}, func() { openCalls++ }); err != nil {
t.Fatalf("dispatchURLSchemeRequest() error = %v", err)
}
},
func() { onboardingCalls++ },
)
if urlCalls != 1 {
t.Fatalf("URL handled %d times, want 1", urlCalls)
}
if openCalls != 1 {
t.Errorf("app opened %d times, want 1", openCalls)
}
if hiddenCalls != 0 {
t.Errorf("hidden startup called %d times, want 0", hiddenCalls)
}
if onboardingCalls != 0 {
t.Errorf("onboarding opened %d times, want 0", onboardingCalls)
}
}
+15 -27
View File
@@ -95,11 +95,15 @@ func (ac *appCallbacks) UIRun(path string) {
}
func (*appCallbacks) UIShow() {
if wv.webview != nil {
openUI("/")
}
func openUI(path string) {
if wv.IsRunning() && wv.webview != nil {
showWindow(wv.webview.Window())
} else {
wv.Run("/")
return
}
wv.Run(path)
}
func (*appCallbacks) UITerminate() {
@@ -138,19 +142,7 @@ func (app *appCallbacks) HandleURLScheme(urlScheme string) {
// handleURLSchemeRequest processes URL scheme requests from other instances
func handleURLSchemeRequest(urlScheme string) {
isConnect, err := parseURLScheme(urlScheme)
if err != nil {
slog.Error("failed to parse URL scheme request", "url", urlScheme, "error", err)
return
}
if isConnect {
handleConnectURLScheme()
} else {
if wv.webview != nil {
showWindow(wv.webview.Window())
}
}
handleURLSchemeInCurrentInstance(urlScheme)
}
func UpdateAvailable(ver string) error {
@@ -161,7 +153,7 @@ func UpdateAvailable(ver string) error {
return app.t.UpdateAvailable(ver)
}
func osRun(shutdown func(), hasCompletedFirstRun, startHidden bool) {
func osRun(shutdown func(), hasCompletedFirstRun, startHidden, showOnboarding bool, urlSchemeRequest string) {
var err error
app.shutdown = shutdown
app.t, err = wintray.NewTray(app)
@@ -205,9 +197,7 @@ func osRun(shutdown func(), hasCompletedFirstRun, startHidden bool) {
}
}
}
if startHidden {
startHiddenTasks()
} else {
runInitialWindowsUI(startHidden, showOnboarding, urlSchemeRequest, startHiddenTasks, handleURLSchemeInCurrentInstance, func() {
ptr := wv.Run("/")
// Set the window icon using the tray icon
@@ -225,7 +215,7 @@ func osRun(shutdown func(), hasCompletedFirstRun, startHidden bool) {
}
centerWindow(ptr)
}
})
if !hasCompletedFirstRun {
// Only create the login shortcut on first start
@@ -408,6 +398,8 @@ func hideWindow(ptr unsafe.Pointer) {
}
}
func setOnboardingWindowStyle(_ unsafe.Pointer, _ bool) {}
func runInBackground() {
exe, err := os.Executable()
if err != nil {
@@ -432,17 +424,13 @@ func drag(ptr unsafe.Pointer) {}
func doubleClick(ptr unsafe.Pointer) {}
// checkAndHandleExistingInstance checks if another instance is running and sends the URL to it
func checkAndHandleExistingInstance(urlSchemeRequest string) bool {
func checkAndHandleExistingInstance(urlSchemeRequest string) {
if urlSchemeRequest == "" {
return false
return
}
// Try to send URL to existing instance using wintray messaging
if wintray.CheckAndSendToExistingInstance(urlSchemeRequest) {
os.Exit(0)
return true
}
// No existing instance, we'll handle it ourselves
return false
}
+40 -5
View File
@@ -24,6 +24,13 @@ import (
"github.com/ollama/ollama/app/webview"
)
const (
defaultWindowWidth = 1360
defaultWindowHeight = 960
onboardingWindowWidth = 900
onboardingWindowHeight = 660
)
type Webview struct {
port int
token string
@@ -237,10 +244,38 @@ func (w *Webview) Run(path string) unsafe.Pointer {
showWindow(wv.Window())
})
wv.Bind("activateOllama", func() {
showWindow(wv.Window())
})
wv.Bind("close", func() {
hideWindow(wv.Window())
})
wv.Bind("setOnboardingWindow", func(enabled bool) {
wv.Dispatch(func() {
if enabled {
wv.SetSize(onboardingWindowWidth, onboardingWindowHeight, webview.HintFixed)
setOnboardingWindowStyle(wv.Window(), true)
return
}
width, height := defaultWindowWidth, defaultWindowHeight
if w.Store != nil {
storedWidth, storedHeight, err := w.Store.WindowSize()
if err != nil {
slog.Error("failed to restore window size", "error", err)
} else if storedWidth > 0 && storedHeight > 0 {
width, height = storedWidth, storedHeight
}
}
wv.SetSize(width, height, webview.HintNone)
wv.SetSize(800, 600, webview.HintMin)
setOnboardingWindowStyle(wv.Window(), false)
})
})
// Webviews do not allow access to the file system by default, so we need to
// bind file system operations here
wv.Bind("selectModelsDirectory", func() {
@@ -450,17 +485,17 @@ func (w *Webview) Run(path string) unsafe.Pointer {
}()
}
width, height := defaultWindowWidth, defaultWindowHeight
if w.Store != nil {
width, height, err := w.Store.WindowSize()
storedWidth, storedHeight, err := w.Store.WindowSize()
if err != nil {
slog.Error("failed to get window size", "error", err)
}
if width > 0 && height > 0 {
wv.SetSize(width, height, webview.HintNone)
} else {
wv.SetSize(800, 600, webview.HintNone)
if storedWidth > 0 && storedHeight > 0 {
width, height = storedWidth, storedHeight
}
}
wv.SetSize(width, height, webview.HintNone)
wv.SetSize(800, 600, webview.HintMin)
w.webview = wv
+32 -22
View File
@@ -14,7 +14,7 @@ import (
// currentSchemaVersion defines the current database schema version.
// Increment this when making schema changes that require migrations.
const currentSchemaVersion = 16
const currentSchemaVersion = 17
// database wraps the SQLite connection.
// SQLite handles its own locking for concurrent access:
@@ -82,7 +82,8 @@ func (db *database) init() error {
websearch_enabled BOOLEAN NOT NULL DEFAULT 0,
selected_model TEXT NOT NULL DEFAULT '',
sidebar_open BOOLEAN NOT NULL DEFAULT 0,
last_home_view TEXT NOT NULL DEFAULT 'launch',
last_home_view TEXT NOT NULL DEFAULT 'chat',
onboarding_version INTEGER NOT NULL DEFAULT 0,
think_enabled BOOLEAN NOT NULL DEFAULT 0,
think_level TEXT NOT NULL DEFAULT '',
cloud_setting_migrated BOOLEAN NOT NULL DEFAULT 0,
@@ -271,6 +272,12 @@ func (db *database) migrate() error {
return fmt.Errorf("migrate v15 to v16: %w", err)
}
version = 16
case 16:
// Existing users should not be shown onboarding after an upgrade.
if err := db.migrateV16ToV17(); err != nil {
return fmt.Errorf("migrate v16 to v17: %w", err)
}
version = 17
default:
// If we have a version we don't recognize, just set it to current
// This might happen during development
@@ -527,7 +534,7 @@ func (db *database) migrateV14ToV15() error {
// migrateV15ToV16 adds the last_home_view column to the settings table
func (db *database) migrateV15ToV16() error {
_, err := db.conn.Exec(`ALTER TABLE settings ADD COLUMN last_home_view TEXT NOT NULL DEFAULT 'launch'`)
_, err := db.conn.Exec(`ALTER TABLE settings ADD COLUMN last_home_view TEXT NOT NULL DEFAULT 'chat'`)
if err != nil && !duplicateColumnError(err) {
return fmt.Errorf("add last_home_view column: %w", err)
}
@@ -540,6 +547,23 @@ func (db *database) migrateV15ToV16() error {
return nil
}
// migrateV16ToV17 adds versioned onboarding state. The schema default stays at
// zero for genuinely new installs, while all existing rows are marked complete
// and moved off the retired launch home view.
func (db *database) migrateV16ToV17() error {
_, err := db.conn.Exec(`ALTER TABLE settings ADD COLUMN onboarding_version INTEGER NOT NULL DEFAULT 0`)
if err != nil && !duplicateColumnError(err) {
return fmt.Errorf("add onboarding_version column: %w", err)
}
_, err = db.conn.Exec(`UPDATE settings SET onboarding_version = 1, last_home_view = 'chat', schema_version = 17`)
if err != nil {
return fmt.Errorf("complete onboarding for existing users: %w", err)
}
return nil
}
// cleanupOrphanedData removes orphaned records that may exist due to the foreign key bug
func (db *database) cleanupOrphanedData() error {
_, err := db.conn.Exec(`
@@ -1188,9 +1212,9 @@ func (db *database) getSettings() (Settings, error) {
var s Settings
err := db.conn.QueryRow(`
SELECT expose, survey, browser, models, agent, tools, working_dir, context_length, turbo_enabled, websearch_enabled, selected_model, sidebar_open, last_home_view, think_enabled, think_level, auto_update_enabled
SELECT expose, survey, browser, models, agent, tools, working_dir, context_length, turbo_enabled, websearch_enabled, selected_model, sidebar_open, last_home_view, onboarding_version, think_enabled, think_level, auto_update_enabled
FROM settings
`).Scan(&s.Expose, &s.Survey, &s.Browser, &s.Models, &s.Agent, &s.Tools, &s.WorkingDir, &s.ContextLength, &s.TurboEnabled, &s.WebSearchEnabled, &s.SelectedModel, &s.SidebarOpen, &s.LastHomeView, &s.ThinkEnabled, &s.ThinkLevel, &s.AutoUpdateEnabled)
`).Scan(&s.Expose, &s.Survey, &s.Browser, &s.Models, &s.Agent, &s.Tools, &s.WorkingDir, &s.ContextLength, &s.TurboEnabled, &s.WebSearchEnabled, &s.SelectedModel, &s.SidebarOpen, &s.LastHomeView, &s.OnboardingVersion, &s.ThinkEnabled, &s.ThinkLevel, &s.AutoUpdateEnabled)
if err != nil {
return Settings{}, fmt.Errorf("get settings: %w", err)
}
@@ -1200,28 +1224,14 @@ func (db *database) getSettings() (Settings, error) {
func (db *database) setSettings(s Settings) error {
lastHomeView := strings.ToLower(strings.TrimSpace(s.LastHomeView))
validLaunchView := map[string]struct{}{
"launch": {},
"openclaw": {},
"claude": {},
"hermes": {},
"codex": {},
"codex-app": {},
"copilot": {},
"opencode": {},
"droid": {},
"pi": {},
}
if lastHomeView != "chat" {
if _, ok := validLaunchView[lastHomeView]; !ok {
lastHomeView = "launch"
}
lastHomeView = "chat"
}
_, err := db.conn.Exec(`
UPDATE settings
SET expose = ?, survey = ?, browser = ?, models = ?, agent = ?, tools = ?, working_dir = ?, context_length = ?, turbo_enabled = ?, websearch_enabled = ?, selected_model = ?, sidebar_open = ?, last_home_view = ?, think_enabled = ?, think_level = ?, auto_update_enabled = ?
`, s.Expose, s.Survey, s.Browser, s.Models, s.Agent, s.Tools, s.WorkingDir, s.ContextLength, s.TurboEnabled, s.WebSearchEnabled, s.SelectedModel, s.SidebarOpen, lastHomeView, s.ThinkEnabled, s.ThinkLevel, s.AutoUpdateEnabled)
SET expose = ?, survey = ?, browser = ?, models = ?, agent = ?, tools = ?, working_dir = ?, context_length = ?, turbo_enabled = ?, websearch_enabled = ?, selected_model = ?, sidebar_open = ?, last_home_view = ?, onboarding_version = ?, think_enabled = ?, think_level = ?, auto_update_enabled = ?
`, s.Expose, s.Survey, s.Browser, s.Models, s.Agent, s.Tools, s.WorkingDir, s.ContextLength, s.TurboEnabled, s.WebSearchEnabled, s.SelectedModel, s.SidebarOpen, lastHomeView, s.OnboardingVersion, s.ThinkEnabled, s.ThinkLevel, s.AutoUpdateEnabled)
if err != nil {
return fmt.Errorf("set settings: %w", err)
}
+50 -3
View File
@@ -135,7 +135,7 @@ func TestMigrationV13ToV14ContextLength(t *testing.T) {
}
}
func TestMigrationV15ToV16LastHomeViewDefaultsToLaunch(t *testing.T) {
func TestMigrationV15ToV16LastHomeViewMigratesToChat(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
@@ -161,8 +161,8 @@ func TestMigrationV15ToV16LastHomeViewDefaultsToLaunch(t *testing.T) {
t.Fatalf("failed to read last_home_view: %v", err)
}
if lastHomeView != "launch" {
t.Fatalf("expected last_home_view to default to launch after migration, got %q", lastHomeView)
if lastHomeView != "chat" {
t.Fatalf("expected last_home_view to migrate to chat, got %q", lastHomeView)
}
version, err := db.getSchemaVersion()
@@ -174,6 +174,53 @@ func TestMigrationV15ToV16LastHomeViewDefaultsToLaunch(t *testing.T) {
}
}
func TestOnboardingVersionDefaultsAndMigration(t *testing.T) {
t.Run("fresh installs need onboarding", func(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "fresh.db")
db, err := newDatabase(dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer db.Close()
settings, err := db.getSettings()
if err != nil {
t.Fatalf("failed to read settings: %v", err)
}
if settings.OnboardingVersion != 0 {
t.Fatalf("expected fresh install onboarding version 0, got %d", settings.OnboardingVersion)
}
})
t.Run("existing installs skip onboarding", func(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "existing.db")
db, err := newDatabase(dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer db.Close()
if _, err := db.conn.Exec(`
ALTER TABLE settings DROP COLUMN onboarding_version;
UPDATE settings SET schema_version = 16;
`); err != nil {
t.Fatalf("failed to seed v16 settings row: %v", err)
}
if err := db.migrate(); err != nil {
t.Fatalf("migration from v16 to v17 failed: %v", err)
}
settings, err := db.getSettings()
if err != nil {
t.Fatalf("failed to read settings: %v", err)
}
if settings.OnboardingVersion != 1 {
t.Fatalf("expected existing install onboarding version 1, got %d", settings.OnboardingVersion)
}
})
}
func TestChatDeletionWithCascade(t *testing.T) {
t.Run("chat deletion cascades to related messages", func(t *testing.T) {
tmpDir := t.TempDir()
+8
View File
@@ -57,6 +57,14 @@ func TestConfigMigration(t *testing.T) {
t.Error("expected has completed first run to be true after migration")
}
settings, err := s.Settings()
if err != nil {
t.Fatalf("failed to get settings: %v", err)
}
if settings.OnboardingVersion != CurrentOnboardingVersion {
t.Fatalf("expected migrated user to skip onboarding, got version %d", settings.OnboardingVersion)
}
// Verify migration is marked as complete
migrated, err := s.db.isConfigMigrated()
if err != nil {
+18 -2
View File
@@ -167,13 +167,19 @@ type Settings struct {
// SidebarOpen indicates if the chat sidebar is open
SidebarOpen bool
// LastHomeView stores the preferred home route target ("chat" or integration name)
// LastHomeView is retained for settings compatibility and resolves to chat.
LastHomeView string
// OnboardingVersion stores the latest onboarding flow the user has completed.
OnboardingVersion int
// AutoUpdateEnabled indicates if automatic updates should be downloaded
AutoUpdateEnabled bool
}
// Keep in sync with CURRENT_ONBOARDING_VERSION in app/ui/app/src/lib/onboarding.ts.
const CurrentOnboardingVersion = 1
type Store struct {
// DBPath allows overriding the default database path (mainly for testing)
DBPath string
@@ -334,6 +340,16 @@ func (s *Store) migrateFromConfig(database *database) error {
if err := database.setHasCompletedFirstRun(hasCompleted); err != nil {
return fmt.Errorf("migrate first time run: %w", err)
}
if hasCompleted {
settings, err := database.getSettings()
if err != nil {
return fmt.Errorf("read settings for onboarding migration: %w", err)
}
settings.OnboardingVersion = CurrentOnboardingVersion
if err := database.setSettings(settings); err != nil {
return fmt.Errorf("migrate onboarding completion: %w", err)
}
}
slog.Info("migrated first run status from config.json", "hasCompleted", hasCompleted)
// Mark as migrated
@@ -393,7 +409,7 @@ func (s *Store) Settings() (Settings, error) {
}
if settings.LastHomeView == "" {
settings.LastHomeView = "launch"
settings.LastHomeView = "chat"
}
return settings, nil
+38 -12
View File
@@ -81,18 +81,18 @@ func TestStore(t *testing.T) {
}
})
t.Run("settings default home view is launch", func(t *testing.T) {
t.Run("settings default home view is chat", func(t *testing.T) {
loaded, err := s.Settings()
if err != nil {
t.Fatal(err)
}
if loaded.LastHomeView != "launch" {
t.Fatalf("expected default LastHomeView to be launch, got %q", loaded.LastHomeView)
if loaded.LastHomeView != "chat" {
t.Fatalf("expected default LastHomeView to be chat, got %q", loaded.LastHomeView)
}
})
t.Run("settings empty home view falls back to launch", func(t *testing.T) {
t.Run("settings empty home view falls back to chat", func(t *testing.T) {
if err := s.SetSettings(Settings{LastHomeView: ""}); err != nil {
t.Fatal(err)
}
@@ -102,12 +102,12 @@ func TestStore(t *testing.T) {
t.Fatal(err)
}
if loaded.LastHomeView != "launch" {
t.Fatalf("expected empty LastHomeView to fall back to launch, got %q", loaded.LastHomeView)
if loaded.LastHomeView != "chat" {
t.Fatalf("expected empty LastHomeView to fall back to chat, got %q", loaded.LastHomeView)
}
})
t.Run("settings disabled home view falls back to launch", func(t *testing.T) {
t.Run("settings retired home view falls back to chat", func(t *testing.T) {
if err := s.SetSettings(Settings{LastHomeView: "claude-desktop"}); err != nil {
t.Fatal(err)
}
@@ -117,12 +117,12 @@ func TestStore(t *testing.T) {
t.Fatal(err)
}
if loaded.LastHomeView != "launch" {
t.Fatalf("expected disabled LastHomeView to fall back to launch, got %q", loaded.LastHomeView)
if loaded.LastHomeView != "chat" {
t.Fatalf("expected retired LastHomeView to fall back to chat, got %q", loaded.LastHomeView)
}
})
t.Run("settings codex app home view is accepted", func(t *testing.T) {
t.Run("settings integration home view falls back to chat", func(t *testing.T) {
if err := s.SetSettings(Settings{LastHomeView: "codex-app"}); err != nil {
t.Fatal(err)
}
@@ -132,8 +132,8 @@ func TestStore(t *testing.T) {
t.Fatal(err)
}
if loaded.LastHomeView != "codex-app" {
t.Fatalf("expected codex-app LastHomeView to be preserved, got %q", loaded.LastHomeView)
if loaded.LastHomeView != "chat" {
t.Fatalf("expected integration LastHomeView to fall back to chat, got %q", loaded.LastHomeView)
}
})
@@ -227,6 +227,32 @@ func TestStore(t *testing.T) {
})
}
func TestOnboardingVersionRoundTrip(t *testing.T) {
s, cleanup := setupTestStore(t)
defer cleanup()
settings, err := s.Settings()
if err != nil {
t.Fatal(err)
}
if settings.OnboardingVersion != 0 {
t.Fatalf("expected onboarding version 0 by default, got %d", settings.OnboardingVersion)
}
settings.OnboardingVersion = 1
if err := s.SetSettings(settings); err != nil {
t.Fatal(err)
}
loaded, err := s.Settings()
if err != nil {
t.Fatal(err)
}
if loaded.OnboardingVersion != 1 {
t.Fatalf("expected onboarding version 1, got %d", loaded.OnboardingVersion)
}
}
// setupTestStore creates a temporary store for testing
func setupTestStore(t *testing.T) (*Store, func()) {
t.Helper()
+2
View File
@@ -415,6 +415,7 @@ export class Settings {
SelectedModel: string;
SidebarOpen: boolean;
LastHomeView: string;
OnboardingVersion: number;
AutoUpdateEnabled: boolean;
constructor(source: any = {}) {
@@ -434,6 +435,7 @@ export class Settings {
this.SelectedModel = source["SelectedModel"];
this.SidebarOpen = source["SidebarOpen"];
this.LastHomeView = source["LastHomeView"];
this.OnboardingVersion = source["OnboardingVersion"];
this.AutoUpdateEnabled = source["AutoUpdateEnabled"];
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 10 KiB

+27
View File
@@ -0,0 +1,27 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchConnectUrl } from "./api";
describe("fetchConnectUrl", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("requests a desktop handoff after account creation", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
signin_url:
"https://ollama.com/connect?name=MacBook&key=public-key",
}),
{ status: 401 },
),
),
);
await expect(fetchConnectUrl()).resolves.toBe(
"https://ollama.com/connect?name=MacBook&key=public-key&launch=true",
);
});
});
+6 -2
View File
@@ -81,7 +81,9 @@ export async function fetchConnectUrl(): Promise<string> {
if (response.status === 401) {
const data = await response.json();
if (data.signin_url) {
return data.signin_url;
const connectUrl = new URL(data.signin_url);
connectUrl.searchParams.set("launch", "true");
return connectUrl.toString();
}
}
@@ -418,7 +420,9 @@ export interface ModelRecommendationsResponse {
recommendations: ModelRecommendation[];
}
export async function getModelRecommendations(): Promise<ModelRecommendation[]> {
export async function getModelRecommendations(): Promise<
ModelRecommendation[]
> {
const response = await fetch(
`${API_BASE}/api/experimental/model-recommendations`,
);
+11 -28
View File
@@ -6,14 +6,12 @@ import { getChat } from "@/api";
import { Link } from "@/components/ui/link";
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { ChatsResponse } from "@/gotypes";
import { CogIcon, RocketLaunchIcon } from "@heroicons/react/24/outline";
import { CogIcon } from "@heroicons/react/24/outline";
// there's a hidden debug feature to copy a chat's data to the clipboard by
// holding shift and clicking this many times within this many seconds
const DEBUG_SHIFT_CLICKS_REQUIRED = 5;
const DEBUG_SHIFT_CLICK_WINDOW_MS = 7000; // 7 seconds
const launchSidebarRequestedKey = "ollama.launchSidebarRequested";
interface ChatSidebarProps {
currentChatId?: string;
}
@@ -268,8 +266,9 @@ export function ChatSidebar({ currentChatId }: ChatSidebarProps) {
<Link
href="/c/new"
mask={{ to: "/" }}
className={`flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-800 dark:text-neutral-100 ${currentChatId === "new" ? "bg-neutral-100 dark:bg-neutral-800" : ""
}`}
className={`flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-800 dark:text-neutral-100 ${
currentChatId === "new" ? "bg-neutral-100 dark:bg-neutral-800" : ""
}`}
draggable={false}
>
<svg
@@ -283,23 +282,6 @@ export function ChatSidebar({ currentChatId }: ChatSidebarProps) {
</svg>
<span className="truncate">New Chat</span>
</Link>
<Link
to="/c/$chatId"
params={{ chatId: "launch" }}
onClick={() => {
if (currentChatId !== "launch") {
sessionStorage.setItem(launchSidebarRequestedKey, "1");
}
}}
className={`flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-800 dark:text-neutral-100 cursor-pointer ${currentChatId === "launch"
? "bg-neutral-100 dark:bg-neutral-800"
: ""
}`}
draggable={false}
>
<RocketLaunchIcon className="h-5 w-5 stroke-current" />
<span className="truncate">Launch</span>
</Link>
{isWindows && (
<Link
href="/settings"
@@ -321,18 +303,19 @@ export function ChatSidebar({ currentChatId }: ChatSidebarProps) {
{group.chats.map((chat) => (
<div
key={chat.id}
className={`allow-context-menu flex items-center relative text-sm text-neutral-800 dark:text-neutral-400 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 ${chat.id === currentChatId
? "bg-neutral-100 text-black dark:bg-neutral-800"
: ""
}`}
className={`allow-context-menu flex items-center relative text-sm text-neutral-800 dark:text-neutral-400 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 ${
chat.id === currentChatId
? "bg-neutral-100 text-black dark:bg-neutral-800"
: ""
}`}
onMouseEnter={() => handleMouseEnter(chat.id)}
onContextMenu={(e) =>
handleContextMenu(
e,
chat.id,
chat.title ||
chat.userExcerpt ||
chat.createdAt.toLocaleString(),
chat.userExcerpt ||
chat.createdAt.toLocaleString(),
)
}
>
+1 -1
View File
@@ -68,7 +68,7 @@ const CopyButton: React.FC<CopyButtonProps> = ({
const iconSize = size === "sm" ? "h-3 w-3" : "h-7 w-7";
const baseClasses =
size === "sm"
? `text-xs px-4 py-2 z-10 rounded-lg hover:cursor-pointer ${className}`
? `text-xs px-4 py-2 z-10 cursor-pointer rounded-lg ${className}`
: `${iconSize} px-1 py-0.5 text-xs cursor-pointer rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 flex items-center justify-center ${className}`;
const icon = isCopied ? (
@@ -1,166 +0,0 @@
import { useSettings } from "@/hooks/useSettings";
import CopyButton from "@/components/CopyButton";
interface LaunchCommand {
id: string;
name: string;
command: string;
description: string;
icon: string;
darkIcon?: string;
iconClassName?: string;
borderless?: boolean;
}
const LAUNCH_COMMANDS: LaunchCommand[] = [
{
id: "claude",
name: "Claude Code",
command: "ollama launch claude",
description: "Anthropic's coding tool with subagents",
icon: "/launch-icons/claude-code.svg",
iconClassName: "h-7 w-7",
},
{
id: "chatgpt",
name: "ChatGPT",
command: "ollama launch chatgpt",
description: "Complete work with ChatGPT",
icon: "/launch-icons/codex-app.png",
iconClassName: "h-full w-full",
},
{
id: "hermes",
name: "Hermes Agent",
command: "ollama launch hermes",
description: "Self-improving AI agent built by Nous Research",
icon: "/launch-icons/hermes-agent.svg",
iconClassName: "h-7 w-7",
},
{
id: "openclaw",
name: "OpenClaw",
command: "ollama launch openclaw",
description: "Personal AI with 100+ skills",
icon: "/launch-icons/openclaw.svg",
},
{
id: "opencode",
name: "OpenCode",
command: "ollama launch opencode",
description: "Anomaly's open-source coding agent",
icon: "/launch-icons/opencode.svg",
iconClassName: "h-7 w-7 rounded",
},
{
id: "codex",
name: "Codex",
command: "ollama launch codex",
description: "OpenAI's open-source coding agent",
icon: "/launch-icons/codex.svg",
darkIcon: "/launch-icons/codex-dark.svg",
iconClassName: "h-7 w-7",
},
{
id: "copilot",
name: "Copilot CLI",
command: "ollama launch copilot",
description: "GitHub's AI coding agent for the terminal",
icon: "/launch-icons/copilot.svg",
darkIcon: "/launch-icons/copilot-dark.svg",
iconClassName: "h-7 w-7",
},
{
id: "droid",
name: "Droid",
command: "ollama launch droid",
description: "Factory's coding agent across terminal and IDEs",
icon: "/launch-icons/droid.svg",
},
{
id: "dsh",
name: "DeepSeek Harness",
command: "ollama launch dsh",
description: "DeepSeek's open-source agent harness",
icon: "/launch-icons/deepseek-harness.svg",
iconClassName: "h-7 w-7",
},
{
id: "pi",
name: "Pi",
command: "ollama launch pi",
description: "Minimal AI agent toolkit with plugin support",
icon: "/launch-icons/pi.svg",
darkIcon: "/launch-icons/pi-dark.svg",
iconClassName: "h-7 w-7",
},
];
export default function LaunchCommands() {
const isWindows = navigator.platform.toLowerCase().includes("win");
const { setSettings } = useSettings();
const renderCommandCard = (item: LaunchCommand) => (
<div key={item.command} className="w-full text-left">
<div className="flex items-start gap-4 sm:gap-5">
<div
aria-hidden="true"
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg overflow-hidden ${item.borderless ? "" : "border border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-900"}`}
>
{item.darkIcon ? (
<picture>
<source srcSet={item.darkIcon} media="(prefers-color-scheme: dark)" />
<img src={item.icon} alt="" className={`${item.iconClassName ?? "h-8 w-8"} rounded-sm`} />
</picture>
) : (
<img src={item.icon} alt="" className={item.borderless ? "h-full w-full rounded-xl" : `${item.iconClassName ?? "h-8 w-8"} rounded-sm`} />
)}
</div>
<div className="min-w-0 flex-1">
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{item.name}
</span>
<p className="mt-0.5 text-xs text-neutral-500 dark:text-neutral-400">
{item.description}
</p>
<div className="mt-2 flex items-center gap-2 rounded-xl border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800 px-3 py-2">
<code className="min-w-0 flex-1 truncate text-xs text-neutral-600 dark:text-neutral-300">
{item.command}
</code>
<CopyButton
content={item.command}
size="md"
title="Copy command to clipboard"
className="text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 hover:bg-neutral-200/60 dark:hover:bg-neutral-700/70"
onCopy={() => {
setSettings({ LastHomeView: item.id }).catch(() => { });
}}
/>
</div>
</div>
</div>
</div>
);
return (
<main className="flex h-screen w-full flex-col relative">
<section
className={`flex-1 overflow-y-auto overscroll-contain relative min-h-0 ${isWindows ? "xl:pt-4" : "xl:pt-8"}`}
>
<div className="max-w-[730px] mx-auto w-full px-4 pt-4 pb-20 sm:px-6 sm:pt-6 sm:pb-24 lg:px-8 lg:pt-8 lg:pb-28">
<h1 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
Launch
</h1>
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
Copy a command and run it in your terminal.
</p>
<div className="mt-6 grid gap-7">
{LAUNCH_COMMANDS.map(renderCommandCard)}
</div>
</div>
</section>
</main>
);
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,170 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import {
FIRST_MODEL_COMMAND,
IntroScreen,
default as Onboarding,
RunOllamaScreen,
WelcomeScreen,
} from "./Onboarding";
import {
authenticationTimeoutAction,
nextOnboardingStep,
onboardingConnectUrl,
} from "@/lib/onboarding";
describe("Onboarding", () => {
it("explains what Ollama is before asking the user to choose a path", () => {
const html = renderToStaticMarkup(<IntroScreen onContinue={vi.fn()} />);
expect(html).toContain("Welcome to Ollama!");
expect(html.indexOf('alt="Ollama waving"')).toBeLessThan(
html.indexOf("Welcome to Ollama!"),
);
expect(html).not.toContain("Run open models locally or in the cloud.");
expect(html.indexOf("Connect your apps")).toBeLessThan(
html.indexOf("Easily switch models"),
);
expect(html.indexOf("Easily switch models")).toBeLessThan(
html.indexOf("Your data stays yours"),
);
expect(html).toContain("Power your existing coding apps with open models");
expect(html).toContain("Swap between frontier models in one click.");
expect(html).toContain("Your prompt data is never logged or trained on.");
expect(html).toContain("Continue");
expect(html).not.toContain("Skip");
});
it("shows the account choice only to signed-out users", () => {
expect(nextOnboardingStep("intro", "continue", false)).toBe("welcome");
expect(nextOnboardingStep("intro", "continue", true)).toBe("run");
expect(nextOnboardingStep("welcome", "local", false)).toBe("run");
});
it("lets an in-flight authentication check finish before timing out", () => {
expect(authenticationTimeoutAction(false, true)).toBe("defer");
expect(authenticationTimeoutAction(false, false)).toBe("fail");
expect(authenticationTimeoutAction(true, true)).toBe("ignore");
});
it("opens the device connection flow without relaunching the app", () => {
expect(
onboardingConnectUrl(
"https://ollama.com/connect?name=MacBook&key=public-key&launch=true",
"signin",
),
).toBe("https://ollama.com/connect?name=MacBook&key=public-key");
expect(
onboardingConnectUrl(
"https://ollama.com/connect?name=MacBook&key=public-key",
"signup",
),
).toBe(
"https://ollama.com/connect?name=MacBook&key=public-key&signup=true",
);
});
it("shows Run Ollama after a successful connection and hides sign in", () => {
const html = renderToStaticMarkup(
<Onboarding
isAuthenticated
isSigningIn={false}
signInError={null}
completionError={null}
onSignIn={vi.fn()}
onRetryCompletion={vi.fn()}
onUseLocal={vi.fn()}
showRun
/>,
);
expect(html).toContain("Run Ollama");
expect(html).not.toContain("Welcome to Ollama");
expect(html).not.toContain("Sign up");
});
it("offers cloud sign-up, local setup, and sign in on the welcome screen", () => {
const html = renderToStaticMarkup(
<WelcomeScreen
isAuthenticated={false}
isSigningIn={false}
signInError={null}
onSignIn={vi.fn()}
onSignUp={vi.fn()}
onLocal={vi.fn()}
/>,
);
expect(html).toContain("Create an account");
expect(html).toContain(
"Create your account for access to faster, larger open models.",
);
expect(html).toContain("Your data is never logged or trained on.");
expect(html).toContain("Sign up");
expect(html).toContain("No thanks, I&#x27;ll use Ollama locally");
expect(html).toContain("Sign in");
expect(html).not.toContain("Skip");
});
it("shows the cloud choice without a sign-in link for authenticated users", () => {
const html = renderToStaticMarkup(
<WelcomeScreen
isAuthenticated
isSigningIn={false}
signInError={null}
onSignIn={vi.fn()}
onSignUp={vi.fn()}
onLocal={vi.fn()}
/>,
);
expect(html).toContain("Create an account");
expect(html).toContain(
"Create your account for access to faster, larger open models.",
);
expect(html).toContain("Your data is never logged or trained on.");
expect(html).not.toContain(">Sign in<");
});
it("shows only the local command on the final page", () => {
const html = renderToStaticMarkup(
<RunOllamaScreen completionError={null} onRetryCompletion={vi.fn()} />,
);
expect(html).toContain("Run Ollama");
expect(html).toContain(FIRST_MODEL_COMMAND);
expect(html).not.toContain("Finish");
expect(html).not.toContain("Sign in");
expect(html).not.toContain("create an account");
});
it("shows the connecting state on the welcome action", () => {
const html = renderToStaticMarkup(
<WelcomeScreen
isAuthenticated={false}
isSigningIn
signInError={null}
onSignIn={vi.fn()}
onSignUp={vi.fn()}
onLocal={vi.fn()}
/>,
);
expect(html).toContain("Finish in your browser…");
expect(html).not.toContain("Waiting for sign in…");
});
it("shows a retryable error when onboarding completion cannot be saved", () => {
const onRetryCompletion = vi.fn();
const html = renderToStaticMarkup(
<RunOllamaScreen
completionError="Unable to save setup. Please try again."
onRetryCompletion={onRetryCompletion}
/>,
);
expect(html).toContain("Unable to save setup. Please try again.");
expect(html).toContain('role="alert"');
expect(html).toContain("Try again");
});
});
+302
View File
@@ -0,0 +1,302 @@
import CopyButton from "@/components/CopyButton";
import Logo from "@/components/Logo";
import { nextOnboardingStep, type OnboardingStep } from "@/lib/onboarding";
import {
ArrowsRightLeftIcon,
CommandLineIcon,
ShieldCheckIcon,
} from "@heroicons/react/24/outline";
import { useEffect, useState, type ReactNode } from "react";
export const FIRST_MODEL_COMMAND = "ollama";
interface ScreenProps {
isSigningIn: boolean;
signInError: string | null;
onSignIn: () => void;
}
interface WelcomeScreenProps extends ScreenProps {
isAuthenticated: boolean;
onLocal: () => void;
onSignUp: () => void;
}
interface RunOllamaScreenProps {
completionError: string | null;
onRetryCompletion: () => void;
}
function TitleBar({ onSignIn }: { onSignIn?: () => void }) {
return (
<header
className="relative flex h-10 shrink-0 items-center justify-center bg-white"
onDoubleClick={() => window.doubleClick?.()}
onMouseDown={() => window.drag?.()}
>
{onSignIn && (
<button
type="button"
className="absolute right-5 top-3 cursor-pointer rounded-md px-2 py-0 text-sm font-normal text-neutral-500 hover:text-neutral-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500"
onClick={onSignIn}
onMouseDown={(event) => event.stopPropagation()}
>
Sign in
</button>
)}
</header>
);
}
function OnboardingIcon({ compact = false }: { compact?: boolean }) {
return (
<div className="flex items-center justify-center">
<Logo
size={compact ? 42 : 54}
containerClassName="mb-0"
showBackground={false}
/>
</div>
);
}
function OnboardingCard({ children }: { children: ReactNode }) {
return (
<section className="flex min-h-0 flex-1 items-center justify-center overflow-y-auto bg-white px-6 pb-10 pt-0">
<div className="flex w-full max-w-[760px] flex-col items-center justify-center bg-white px-10 py-6 text-center">
{children}
</div>
</section>
);
}
const OLLAMA_FEATURES = [
{
title: "Connect your apps",
description: "Power your existing coding apps with open models",
icon: CommandLineIcon,
},
{
title: "Easily switch models",
description: "Swap between frontier models in one click.",
icon: ArrowsRightLeftIcon,
},
{
title: "Your data stays yours",
description: "Your prompt data is never logged or trained on.",
icon: ShieldCheckIcon,
},
];
export function IntroScreen({ onContinue }: { onContinue: () => void }) {
return (
<main className="flex h-screen w-full flex-col overflow-hidden bg-white text-neutral-950">
<TitleBar />
<section className="flex min-h-0 flex-1 items-center justify-center overflow-y-auto px-6 pb-10">
<div className="mx-auto flex min-h-full w-full max-w-[620px] flex-col items-center justify-center py-4 text-center">
<div className="flex flex-col items-center justify-center gap-2">
<img
src="/hello.png"
alt="Ollama waving"
className="h-[72px] w-[72px] select-none object-contain"
draggable={false}
/>
<h1 className="font-rounded text-2xl font-medium leading-8">
Welcome to Ollama!
</h1>
</div>
<div className="mx-auto mt-8 flex w-fit max-w-full flex-col gap-6 text-left">
{OLLAMA_FEATURES.map((feature) => {
const Icon = feature.icon;
return (
<div key={feature.title} className="flex items-start gap-4">
<Icon className="mt-0.5 h-7 w-7 shrink-0 stroke-[1.5] text-neutral-700" />
<div>
<h2 className="text-sm font-medium text-neutral-950">
{feature.title}
</h2>
<p className="mt-0.5 text-[13px] leading-5 text-neutral-500">
{feature.description}
</p>
</div>
</div>
);
})}
</div>
<button
type="button"
className="mt-8 flex h-11 w-full max-w-[240px] cursor-pointer items-center justify-center rounded-full bg-neutral-900 px-5 font-sans text-sm font-normal text-white transition-colors hover:bg-neutral-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500"
onClick={onContinue}
>
Continue
</button>
</div>
</section>
</main>
);
}
function InlineError({
message,
className = "mt-3 text-sm",
}: {
message: string | null;
className?: string;
}) {
if (!message) return null;
return (
<p role="alert" className={`${className} text-red-600`}>
{message}
</p>
);
}
export function WelcomeScreen({
isAuthenticated,
isSigningIn,
signInError,
onSignIn,
onSignUp,
onLocal,
}: WelcomeScreenProps) {
return (
<main className="flex min-h-screen w-full flex-col bg-white text-neutral-950">
<TitleBar onSignIn={isAuthenticated ? undefined : onSignIn} />
<OnboardingCard>
<OnboardingIcon />
<h1 className="mt-7 font-rounded text-2xl font-medium leading-8">
Create an account
</h1>
<p className="mt-3 max-w-[400px] text-sm leading-6 text-neutral-400">
Create your account for access to faster, larger open models.
</p>
<p className="mt-1 max-w-[400px] text-sm leading-6 text-neutral-400">
Your data is never logged or trained on.
</p>
<div className="mt-7 flex w-full max-w-[240px] flex-col items-center">
<button
type="button"
className="flex h-11 w-full cursor-pointer items-center justify-center rounded-full bg-neutral-900 px-5 font-sans text-sm font-normal text-white transition-colors hover:bg-neutral-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500 disabled:cursor-wait disabled:opacity-70"
onClick={onSignUp}
disabled={isSigningIn}
aria-busy={isSigningIn}
>
{isSigningIn ? "Finish in your browser…" : "Sign up"}
</button>
<button
type="button"
className="mt-2 cursor-pointer rounded-md px-3 py-2 text-sm font-normal text-neutral-600 underline decoration-neutral-300 underline-offset-4 hover:text-neutral-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500"
onClick={onLocal}
>
No thanks, I&apos;ll use Ollama locally
</button>
<InlineError message={signInError} />
</div>
</OnboardingCard>
</main>
);
}
export function RunOllamaScreen({
completionError,
onRetryCompletion,
}: RunOllamaScreenProps) {
return (
<main className="flex min-h-screen w-full flex-col bg-white text-neutral-950">
<TitleBar />
<OnboardingCard>
<OnboardingIcon compact />
<h1 className="mt-6 font-rounded text-[22px] font-medium leading-7">
Run Ollama
</h1>
<div className="mt-6 grid h-12 w-full max-w-[330px] grid-cols-[minmax(0,1fr)_32px] items-center rounded-full bg-neutral-100 px-4 pr-3">
<code className="min-w-0 truncate text-left font-mono text-sm">
{FIRST_MODEL_COMMAND}
</code>
<CopyButton
content={FIRST_MODEL_COMMAND}
size="md"
title="Copy command to clipboard"
className="shrink-0 text-neutral-400 hover:!bg-transparent hover:!text-neutral-400 dark:hover:!bg-transparent"
/>
</div>
<p className="mt-3 max-w-xs text-[13px] leading-5 text-neutral-400">
Run this command in your terminal to get started.
</p>
<InlineError message={completionError} />
{completionError && (
<button
type="button"
className="mt-2 cursor-pointer rounded-md px-3 py-1 text-sm font-normal text-neutral-600 underline decoration-neutral-300 underline-offset-4 hover:text-neutral-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500"
onClick={onRetryCompletion}
>
Try again
</button>
)}
</OnboardingCard>
</main>
);
}
interface OnboardingProps extends ScreenProps {
completionError: string | null;
isAuthenticated: boolean;
onRetryCompletion: () => void;
onSignUp: () => void;
onUseLocal: () => void;
showRun: boolean;
}
export default function Onboarding(props: OnboardingProps) {
const [step, setStep] = useState<OnboardingStep>("intro");
useEffect(() => {
window.setOnboardingWindow?.(true);
return () => window.setOnboardingWindow?.(false);
}, []);
if (props.showRun || step === "run") {
return (
<RunOllamaScreen
completionError={props.completionError}
onRetryCompletion={props.onRetryCompletion}
/>
);
}
if (step === "intro") {
return (
<IntroScreen
onContinue={() => {
const nextStep = nextOnboardingStep(
step,
"continue",
props.isAuthenticated,
);
if (nextStep === "run") props.onUseLocal();
setStep(nextStep);
}}
/>
);
}
return (
<WelcomeScreen
{...props}
onLocal={() => {
props.onUseLocal();
setStep((current) =>
nextOnboardingStep(current, "local", props.isAuthenticated),
);
}}
/>
);
}
+6 -26
View File
@@ -12,13 +12,10 @@ import {
BoltIcon,
WrenchIcon,
CloudIcon,
XMarkIcon,
CogIcon,
ArrowLeftIcon,
ArrowDownTrayIcon,
} from "@heroicons/react/20/solid";
import { Settings as SettingsType } from "@/gotypes";
import { useNavigate } from "@tanstack/react-router";
import { useUser } from "@/hooks/useUser";
import { useCloudStatus } from "@/hooks/useCloudStatus";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
@@ -61,7 +58,6 @@ export default function Settings() {
const [isAwaitingConnection, setIsAwaitingConnection] = useState(false);
const [connectionError, setConnectionError] = useState<string | null>(null);
const [pollingInterval, setPollingInterval] = useState<number | null>(null);
const navigate = useNavigate();
const {
cloudDisabled,
cloudStatus,
@@ -273,10 +269,6 @@ export default function Settings() {
}
const isWindows = navigator.platform.toLowerCase().includes("win");
const handleCloseSettings = () => {
const chatId = settings.LastHomeView === "chat" ? "new" : "launch";
navigate({ to: "/c/$chatId", params: { chatId } });
};
return (
<main className="flex h-screen w-full flex-col select-none dark:bg-neutral-900">
@@ -288,24 +280,8 @@ export default function Settings() {
<h1
className={`${isWindows ? "pl-4" : "pl-24"} flex items-center font-rounded text-md font-medium dark:text-white`}
>
{isWindows && (
<button
onClick={handleCloseSettings}
className="hover:bg-neutral-100 mr-3 dark:hover:bg-neutral-800 rounded-full p-1.5"
>
<ArrowLeftIcon className="w-5 h-5 dark:text-white" />
</button>
)}
Settings
</h1>
{!isWindows && (
<button
onClick={handleCloseSettings}
className="p-1 hover:bg-neutral-100 mr-3 dark:hover:bg-neutral-800 rounded-full"
>
<XMarkIcon className="w-6 h-6 dark:text-white" />
</button>
)}
</header>
<div className="w-full p-6 overflow-y-auto flex-1 overscroll-contain">
<div className="space-y-4 max-w-2xl mx-auto">
@@ -463,7 +439,9 @@ export default function Settings() {
<div className="flex-shrink-0">
<Switch
checked={settings.AutoUpdateEnabled}
onChange={(checked) => handleChange("AutoUpdateEnabled", checked)}
onChange={(checked) =>
handleChange("AutoUpdateEnabled", checked)
}
/>
</div>
</div>
@@ -544,7 +522,9 @@ export default function Settings() {
</Description>
<div className="mt-3">
<Slider
value={settings.ContextLength || defaultContextLength || 0}
value={
settings.ContextLength || defaultContextLength || 0
}
onChange={(value) => {
handleChange("ContextLength", value);
}}
+4 -1
View File
@@ -10,6 +10,7 @@ interface SettingsState {
selectedModel: string;
sidebarOpen: boolean;
lastHomeView: string;
onboardingVersion: number;
thinkEnabled: boolean;
thinkLevel: string;
}
@@ -23,6 +24,7 @@ type SettingsUpdate = Partial<{
SelectedModel: string;
SidebarOpen: boolean;
LastHomeView: string;
OnboardingVersion: number;
}>;
export function useSettings() {
@@ -52,7 +54,8 @@ export function useSettings() {
thinkLevel: settingsData?.settings?.ThinkLevel ?? "none",
selectedModel: settingsData?.settings?.SelectedModel ?? "",
sidebarOpen: settingsData?.settings?.SidebarOpen ?? false,
lastHomeView: settingsData?.settings?.LastHomeView ?? "launch",
lastHomeView: settingsData?.settings?.LastHomeView ?? "chat",
onboardingVersion: settingsData?.settings?.OnboardingVersion ?? 0,
}),
[settingsData?.settings],
);
+13
View File
@@ -8,6 +8,19 @@
"SF Pro Rounded", ui-sans-serif, system-ui, "Segoe UI", sans-serif;
}
@layer base {
a[href],
button:not(:disabled),
[role="button"]:not([aria-disabled="true"]) {
cursor: pointer;
}
button:disabled,
[role="button"][aria-disabled="true"] {
cursor: not-allowed;
}
}
@media (prefers-color-scheme: dark) {
/* Dark mode styles go here */
:root {
+47
View File
@@ -0,0 +1,47 @@
// Keep in sync with store.CurrentOnboardingVersion in app/store/store.go.
export const CURRENT_ONBOARDING_VERSION = 1;
export type OnboardingAuthMode = "signin" | "signup";
export function onboardingConnectUrl(
connectUrl: string,
mode: OnboardingAuthMode,
): string {
const url = new URL(connectUrl);
url.searchParams.delete("launch");
if (mode === "signup") {
url.searchParams.set("signup", "true");
} else {
url.searchParams.delete("signup");
}
return url.toString();
}
export const AUTHENTICATION_TIMEOUT_MS = 5 * 60 * 1000;
export type OnboardingStep = "intro" | "welcome" | "run";
export type OnboardingAction = "continue" | "local";
export type AuthenticationTimeoutAction = "ignore" | "defer" | "fail";
export function nextOnboardingStep(
step: OnboardingStep,
action: OnboardingAction,
isAuthenticated: boolean,
): OnboardingStep {
if (action === "local") return "run";
if (step === "intro") return isAuthenticated ? "run" : "welcome";
return step;
}
export function authenticationTimeoutAction(
settled: boolean,
checking: boolean,
): AuthenticationTimeoutAction {
if (settled) return "ignore";
if (checking) return "defer";
return "fail";
}
export function homeChatId(): "new" {
return "new";
}
+26 -3
View File
@@ -12,6 +12,7 @@
import { Route as rootRoute } from './routes/__root'
import { Route as SettingsImport } from './routes/settings'
import { Route as OnboardingImport } from './routes/onboarding'
import { Route as IndexImport } from './routes/index'
import { Route as CChatIdImport } from './routes/c.$chatId'
@@ -23,6 +24,12 @@ const SettingsRoute = SettingsImport.update({
getParentRoute: () => rootRoute,
} as any)
const OnboardingRoute = OnboardingImport.update({
id: '/onboarding',
path: '/onboarding',
getParentRoute: () => rootRoute,
} as any)
const IndexRoute = IndexImport.update({
id: '/',
path: '/',
@@ -46,6 +53,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexImport
parentRoute: typeof rootRoute
}
'/onboarding': {
id: '/onboarding'
path: '/onboarding'
fullPath: '/onboarding'
preLoaderRoute: typeof OnboardingImport
parentRoute: typeof rootRoute
}
'/settings': {
id: '/settings'
path: '/settings'
@@ -67,12 +81,14 @@ declare module '@tanstack/react-router' {
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/onboarding': typeof OnboardingRoute
'/settings': typeof SettingsRoute
'/c/$chatId': typeof CChatIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/onboarding': typeof OnboardingRoute
'/settings': typeof SettingsRoute
'/c/$chatId': typeof CChatIdRoute
}
@@ -80,27 +96,30 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRoute
'/': typeof IndexRoute
'/onboarding': typeof OnboardingRoute
'/settings': typeof SettingsRoute
'/c/$chatId': typeof CChatIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/settings' | '/c/$chatId'
fullPaths: '/' | '/onboarding' | '/settings' | '/c/$chatId'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/settings' | '/c/$chatId'
id: '__root__' | '/' | '/settings' | '/c/$chatId'
to: '/' | '/onboarding' | '/settings' | '/c/$chatId'
id: '__root__' | '/' | '/onboarding' | '/settings' | '/c/$chatId'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
OnboardingRoute: typeof OnboardingRoute
SettingsRoute: typeof SettingsRoute
CChatIdRoute: typeof CChatIdRoute
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
OnboardingRoute: OnboardingRoute,
SettingsRoute: SettingsRoute,
CChatIdRoute: CChatIdRoute,
}
@@ -116,6 +135,7 @@ export const routeTree = rootRoute
"filePath": "__root.tsx",
"children": [
"/",
"/onboarding",
"/settings",
"/c/$chatId"
]
@@ -123,6 +143,9 @@ export const routeTree = rootRoute
"/": {
"filePath": "index.tsx"
},
"/onboarding": {
"filePath": "onboarding.tsx"
},
"/settings": {
"filePath": "settings.tsx"
},
+13 -78
View File
@@ -1,40 +1,25 @@
import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useChat } from "@/hooks/useChats";
import Chat from "@/components/Chat";
import { getChat } from "@/api";
import { SidebarLayout } from "@/components/layout/layout";
import { ChatSidebar } from "@/components/ChatSidebar";
import LaunchCommands from "@/components/LaunchCommands";
import { useEffect, useRef } from "react";
import { useEffect } from "react";
import { useSettings } from "@/hooks/useSettings";
const launchSidebarRequestedKey = "ollama.launchSidebarRequested";
const launchSidebarSeenKey = "ollama.launchSidebarSeen";
const fallbackSessionState = new Map<string, string>();
function getSessionState() {
if (typeof sessionStorage !== "undefined") {
return sessionStorage;
}
return {
getItem(key: string) {
return fallbackSessionState.get(key) ?? null;
},
setItem(key: string, value: string) {
fallbackSessionState.set(key, value);
},
removeItem(key: string) {
fallbackSessionState.delete(key);
},
};
}
export const Route = createFileRoute("/c/$chatId")({
component: RouteComponent,
beforeLoad: ({ params }) => {
if (params.chatId === "launch") {
throw redirect({
to: "/c/$chatId",
params: { chatId: "new" },
mask: { to: "/" },
});
}
},
loader: async ({ context, params }) => {
// Skip loading for special non-chat views
if (params.chatId !== "new" && params.chatId !== "launch") {
if (params.chatId !== "new") {
context.queryClient.ensureQueryData({
queryKey: ["chat", params.chatId],
queryFn: () => getChat(params.chatId),
@@ -47,61 +32,19 @@ export const Route = createFileRoute("/c/$chatId")({
function RouteComponent() {
const { chatId } = Route.useParams();
const { settingsData, setSettings } = useSettings();
const previousChatIdRef = useRef<string | null>(null);
// Always call hooks at the top level - use a flag to skip data when chatId is a special view
const {
data: chatData,
isLoading: chatLoading,
error: chatError,
} = useChat(chatId === "new" || chatId === "launch" ? "" : chatId);
} = useChat(chatId === "new" ? "" : chatId);
useEffect(() => {
if (!settingsData) {
return;
}
const previousChatId = previousChatIdRef.current;
previousChatIdRef.current = chatId;
if (chatId === "launch") {
const sessionState = getSessionState();
const shouldOpenSidebar =
previousChatId !== "launch" &&
(() => {
if (sessionState.getItem(launchSidebarRequestedKey) === "1") {
sessionState.removeItem(launchSidebarRequestedKey);
sessionState.setItem(launchSidebarSeenKey, "1");
return true;
}
if (sessionState.getItem(launchSidebarSeenKey) !== "1") {
sessionState.setItem(launchSidebarSeenKey, "1");
return true;
}
return false;
})();
const updates: { LastHomeView?: string; SidebarOpen?: boolean } = {};
if (settingsData.LastHomeView !== "launch") {
updates.LastHomeView = "launch";
}
if (shouldOpenSidebar && !settingsData.SidebarOpen) {
updates.SidebarOpen = true;
}
if (Object.keys(updates).length === 0) {
return;
}
setSettings(updates).catch(() => {
// Best effort persistence for home view preference.
});
return;
}
if (settingsData.LastHomeView === "chat") {
return;
}
@@ -120,14 +63,6 @@ function RouteComponent() {
);
}
if (chatId === "launch") {
return (
<SidebarLayout sidebar={<ChatSidebar currentChatId={chatId} />}>
<LaunchCommands />
</SidebarLayout>
);
}
// Handle existing chat case
if (chatLoading) {
return (
+6 -2
View File
@@ -1,5 +1,6 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { getSettings } from "@/api";
import { CURRENT_ONBOARDING_VERSION, homeChatId } from "@/lib/onboarding";
export const Route = createFileRoute("/")({
beforeLoad: async ({ context }) => {
@@ -7,8 +8,11 @@ export const Route = createFileRoute("/")({
queryKey: ["settings"],
queryFn: getSettings,
});
const chatId =
settingsData?.settings?.LastHomeView === "chat" ? "new" : "launch";
if (settingsData.settings.OnboardingVersion < CURRENT_ONBOARDING_VERSION) {
throw redirect({ to: "/onboarding" });
}
const chatId = homeChatId();
throw redirect({
to: "/c/$chatId",
+195
View File
@@ -0,0 +1,195 @@
import Onboarding from "@/components/Onboarding";
import { getSettings } from "@/api";
import { useSettings } from "@/hooks/useSettings";
import { useUser } from "@/hooks/useUser";
import {
AUTHENTICATION_TIMEOUT_MS,
authenticationTimeoutAction,
CURRENT_ONBOARDING_VERSION,
homeChatId,
onboardingConnectUrl,
type OnboardingAuthMode,
} from "@/lib/onboarding";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useCallback, useEffect, useRef, useState } from "react";
export const Route = createFileRoute("/onboarding")({
beforeLoad: async ({ context }) => {
// Let developers review onboarding without resetting their local app data.
if (
import.meta.env.DEV &&
new URLSearchParams(window.location.search).get("preview") === "1"
) {
return;
}
const settingsData = await context.queryClient.ensureQueryData({
queryKey: ["settings"],
queryFn: getSettings,
});
if (settingsData.settings.OnboardingVersion >= CURRENT_ONBOARDING_VERSION) {
const chatId = homeChatId();
throw redirect({
to: "/c/$chatId",
params: { chatId },
mask: { to: "/" },
});
}
},
component: OnboardingRoute,
});
function OnboardingRoute() {
const { settingsData, setSettings } = useSettings();
const { fetchConnectUrl, refetchUser, isAuthenticated } = useUser();
const [isAwaitingAuth, setIsAwaitingAuth] = useState(false);
const [showRun, setShowRun] = useState(false);
const [signInError, setSignInError] = useState<string | null>(null);
const [completionError, setCompletionError] = useState<string | null>(null);
const authAttemptRef = useRef(0);
const completeOnboarding = useCallback(async (): Promise<boolean> => {
setCompletionError(null);
try {
if (!settingsData) {
throw new Error("Settings are not loaded");
}
await setSettings({
OnboardingVersion: CURRENT_ONBOARDING_VERSION,
});
return true;
} catch (error) {
console.error("Failed to save onboarding state:", error);
setCompletionError("Unable to save setup. Please try again.");
return false;
}
}, [setSettings, settingsData]);
const showRunScreen = useCallback(() => {
setShowRun(true);
void completeOnboarding();
}, [completeOnboarding]);
const retryCompletion = useCallback(() => {
void completeOnboarding();
}, [completeOnboarding]);
const authenticate = useCallback(
async (mode: OnboardingAuthMode) => {
setSignInError(null);
if (isAuthenticated) {
showRunScreen();
return;
}
const authAttempt = ++authAttemptRef.current;
setIsAwaitingAuth(true);
try {
const result = await fetchConnectUrl();
if (authAttempt !== authAttemptRef.current) return;
if (!result.data) {
throw new Error("No sign-in URL was returned");
}
window.open(onboardingConnectUrl(result.data, mode), "_blank");
} catch (error) {
if (authAttempt !== authAttemptRef.current) return;
console.error("Failed to start sign in:", error);
setIsAwaitingAuth(false);
setSignInError("Unable to start sign in. Please try again.");
}
},
[fetchConnectUrl, isAuthenticated, showRunScreen],
);
const signIn = useCallback(() => authenticate("signin"), [authenticate]);
const signUp = useCallback(() => authenticate("signup"), [authenticate]);
const useLocal = useCallback(() => {
authAttemptRef.current += 1;
setIsAwaitingAuth(false);
setSignInError(null);
showRunScreen();
}, [showRunScreen]);
useEffect(() => {
if (!isAwaitingAuth) return;
let checking = false;
let settled = false;
let timeoutPending = false;
const authAttempt = authAttemptRef.current;
const failConnection = () => {
if (settled || authAttempt !== authAttemptRef.current) return;
settled = true;
setIsAwaitingAuth(false);
setSignInError(
"Connection is taking longer than expected. Please try again.",
);
};
const checkConnection = async () => {
if (checking || settled || authAttempt !== authAttemptRef.current) return;
checking = true;
try {
const result = await refetchUser();
if (
!settled &&
authAttempt === authAttemptRef.current &&
result.data?.name
) {
settled = true;
setIsAwaitingAuth(false);
showRunScreen();
window.activateOllama?.();
}
} catch (error) {
console.error("Failed to check sign-in status:", error);
} finally {
checking = false;
if (timeoutPending) failConnection();
}
};
void checkConnection();
const pollingInterval = window.setInterval(checkConnection, 1000);
const timeout = window.setTimeout(() => {
const action = authenticationTimeoutAction(settled, checking);
if (action === "ignore") return;
if (action === "defer") {
timeoutPending = true;
return;
}
failConnection();
}, AUTHENTICATION_TIMEOUT_MS);
window.addEventListener("focus", checkConnection);
return () => {
settled = true;
window.clearInterval(pollingInterval);
window.clearTimeout(timeout);
window.removeEventListener("focus", checkConnection);
};
}, [isAwaitingAuth, refetchUser, showRunScreen]);
return (
<Onboarding
completionError={completionError}
isAuthenticated={isAuthenticated}
isSigningIn={isAwaitingAuth}
signInError={signInError}
onSignIn={signIn}
onSignUp={signUp}
onRetryCompletion={retryCompletion}
onUseLocal={useLocal}
showRun={showRun}
/>
);
}
+2
View File
@@ -24,6 +24,8 @@ declare global {
webview?: WebviewAPI;
drag?: () => void;
doubleClick?: () => void;
activateOllama?: () => void;
setOnboardingWindow?: (enabled: boolean) => void;
menu: (items: MenuItem[]) => Promise<string | null>;
OLLAMA_TOOLS?: boolean;
OLLAMA_WEBSEARCH?: boolean;
+12 -2
View File
@@ -1464,11 +1464,21 @@ func (s *Server) settings(w http.ResponseWriter, r *http.Request) error {
return fmt.Errorf("failed to load settings: %w", err)
}
var settings store.Settings
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
var request struct {
store.Settings
OnboardingVersion *int
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
return fmt.Errorf("invalid request body: %w", err)
}
settings := request.Settings
if request.OnboardingVersion == nil {
settings.OnboardingVersion = old.OnboardingVersion
} else {
settings.OnboardingVersion = *request.OnboardingVersion
}
if err := s.Store.SetSettings(settings); err != nil {
return fmt.Errorf("failed to save settings: %w", err)
}
+49
View File
@@ -718,6 +718,55 @@ func TestSettingsToggleAutoUpdateOff_CancelsDownload(t *testing.T) {
}
}
func TestSettingsPreservesOnboardingVersionWhenOmitted(t *testing.T) {
testStore := &store.Store{
DBPath: filepath.Join(t.TempDir(), "db.sqlite"),
}
defer testStore.Close()
settings, err := testStore.Settings()
if err != nil {
t.Fatal(err)
}
settings.OnboardingVersion = 1
if err := testStore.SetSettings(settings); err != nil {
t.Fatal(err)
}
payload, err := json.Marshal(settings)
if err != nil {
t.Fatal(err)
}
var fields map[string]any
if err := json.Unmarshal(payload, &fields); err != nil {
t.Fatal(err)
}
delete(fields, "OnboardingVersion")
payload, err = json.Marshal(fields)
if err != nil {
t.Fatal(err)
}
server := &Server{
Store: testStore,
Restart: func() {},
}
req := httptest.NewRequest("POST", "/api/v1/settings", bytes.NewReader(payload))
rr := httptest.NewRecorder()
if err := server.settings(rr, req); err != nil {
t.Fatalf("settings() error = %v", err)
}
saved, err := testStore.Settings()
if err != nil {
t.Fatal(err)
}
if saved.OnboardingVersion != 1 {
t.Fatalf("OnboardingVersion = %d, want 1", saved.OnboardingVersion)
}
}
func TestSettingsToggleAutoUpdateOn_WithPendingUpdate_ShowsNotification(t *testing.T) {
testStore := &store.Store{
DBPath: filepath.Join(t.TempDir(), "db.sqlite"),
+10 -9
View File
@@ -80,7 +80,7 @@ func (t *winTray) wndProc(hWnd windows.Handle, message uint32, wParam, lParam ui
t.app.DoUpdate()
case openUIMenuID:
// UI must be initialized on this thread so don't use the callbacks
t.app.UIShow()
t.app.UIRun("/")
case settingsUIMenuID:
// UI must be initialized on this thread so don't use the callbacks
t.app.UIRun("/settings")
@@ -174,14 +174,7 @@ func (t *winTray) wndProc(hWnd windows.Handle, message uint32, wParam, lParam ui
}
}
case uint32(FOCUS_WINDOW_MSG_ID):
// Handle focus window request from another instance
if t.app.UIRunning() {
// If UI is already running, just show it
t.app.UIShow()
} else {
// If UI is not running, start it
t.app.UIRun("/")
}
focusUI(t.app)
lResult = 1 // Return non-zero to indicate success
default:
// Calls the default window procedure to provide default processing for any window messages that an application does not process.
@@ -197,6 +190,14 @@ func (t *winTray) wndProc(hWnd windows.Handle, message uint32, wParam, lParam ui
return
}
func focusUI(app AppCallbacks) {
if app.UIRunning() {
app.UIShow()
return
}
app.UIRun("/")
}
func (t *winTray) Quit() {
// slog.Debug("XXX in winTray.Quit")
t.quitting = true
+43
View File
@@ -0,0 +1,43 @@
//go:build windows
package wintray
import "testing"
type lifecycleApp struct {
running bool
runPath string
showCall bool
}
func (a *lifecycleApp) UIRun(path string) { a.runPath = path }
func (a *lifecycleApp) UIShow() { a.showCall = true }
func (a *lifecycleApp) UITerminate() {}
func (a *lifecycleApp) UIRunning() bool { return a.running }
func (a *lifecycleApp) Quit() {}
func (a *lifecycleApp) DoUpdate() {}
func TestFocusUICreatesOrShowsWindow(t *testing.T) {
tests := []struct {
name string
running bool
wantRun string
wantShow bool
}{
{name: "creates window when tray only", wantRun: "/"},
{name: "shows existing window", running: true, wantShow: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app := &lifecycleApp{running: tt.running}
focusUI(app)
if app.runPath != tt.wantRun {
t.Errorf("run path = %q, want %q", app.runPath, tt.wantRun)
}
if app.showCall != tt.wantShow {
t.Errorf("show called = %v, want %v", app.showCall, tt.wantShow)
}
})
}
}
+2 -2
View File
@@ -28,10 +28,10 @@ const (
)
func (t *winTray) initMenus() error {
if err := t.addOrUpdateMenuItem(openUIMenuID, 0, openUIMenuTitle, false); err != nil {
if err := t.addOrUpdateMenuItem(settingsUIMenuID, 0, settingsUIMenuTitle, false); err != nil {
return fmt.Errorf("unable to create menu entries %w", err)
}
if err := t.addOrUpdateMenuItem(settingsUIMenuID, 0, settingsUIMenuTitle, false); err != nil {
if err := t.addOrUpdateMenuItem(openUIMenuID, 0, openUIMenuTitle, false); err != nil {
return fmt.Errorf("unable to create menu entries %w", err)
}
if err := t.addOrUpdateMenuItem(diagLogsMenuID, 0, diagLogsMenuTitle, false); err != nil {
+1 -1
View File
@@ -12,6 +12,6 @@ const (
updateAvailableMenuTitle = "An update is available"
updateMenuTitle = "Restart to update"
diagLogsMenuTitle = "View logs"
openUIMenuTitle = "Open Ollama"
openUIMenuTitle = "Open Ollama Chat"
settingsUIMenuTitle = "Settings..."
)