picker-first startup with per-project sticky override (T-115)

Boot used to auto-open app.lastProject and fall back to the CWD; new
default is the welcome screen as the project picker. Sticky-open is
opt-in: a checkbox on each recent-projects row toggles a
startupSticky flag, and clide auto-opens iff exactly one row has it.
Two-or-more, or none, ⇒ picker (unambiguous user intent).

RecentProject gains the boolean (persisted in app.recentProjects);
ProjectManager exposes stickyProjectPath, openStickyOrNothing,
setStickyStartup, isStickyStartup, and preserves the flag across
reopens.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-18 10:26:12 +02:00
co-authored by Claude
parent 980f60f798
commit 7046bf9c70
7 changed files with 161 additions and 9 deletions
@@ -1813,3 +1813,6 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by,
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-104', 'status', 'in_progress', 'done', NULL, '2026-05-18 07:53:10', '2026-05-18 07:53:10', '2026-05-18 07:53:10', NULL, 'ce2b66658977edc3fddc78079d7852e5', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-109', 'status', 'backlog', 'in_progress', NULL, '2026-05-18 08:02:06', '2026-05-18 08:02:06', '2026-05-18 08:02:06', NULL, 'ee75fea173ae492a6ad8a1d888a2142d', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-109', 'status', 'in_progress', 'done', NULL, '2026-05-18 08:07:23', '2026-05-18 08:07:23', '2026-05-18 08:07:23', NULL, '9dd61b76946909aa44edaf9d1c1741a0', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-121', 'status', 'backlog', 'done', NULL, '2026-05-18 08:22:19', '2026-05-18 08:22:19', '2026-05-18 08:22:19', NULL, '861af9256207314d681e51abc77c386f', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-115', 'status', 'backlog', 'in_progress', NULL, '2026-05-18 08:23:34', '2026-05-18 08:23:34', '2026-05-18 08:23:34', NULL, '94f4a894c1dddfa87ee8306a9758c808', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-115', 'status', 'in_progress', 'done', NULL, '2026-05-18 08:26:05', '2026-05-18 08:26:05', '2026-05-18 08:26:05', NULL, 'b8978359fc1b83ea0bcfc6cd5d34d5de', 1) ON CONFLICT(hash) DO NOTHING;
+25
View File
@@ -1895,3 +1895,28 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority,
Order matters: 2 3 1 (clean changelog, release, then doc the workflow).
Source: consultants.md "Docs — Findings".', 'done', 'medium', NULL, NULL, NULL, '2026-05-17 18:48:03', '2026-05-18 08:07:23', NULL, '7c1f64a5aef6e3e92157691a2b192beb', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-121', 'task', 'T-97', 'cut interim release to drain Unreleased (split from T-109)', 'Item 3 of T-109. With CONTRIBUTING.md landed and [Unreleased] consolidated, the remaining piece is cutting an actual release so the ~80-commit Unreleased backlog drains into a dated heading.
Per .claude/skills/git-commit/SKILL.md "Cutting a release":
1. Move every [Unreleased] entry under a new `## [X.Y.Z] YYYY-MM-DD` heading.
2. Leave an empty [Unreleased] skeleton.
3. Bump pubspec.yaml version: (drop the -dev suffix for the tag).
4. Commit subject: `release vX.Y.Z`.
Version pick is the user''s call likely 2.1.0 given the scope since 2.0.0 (D-66 95% coverage gate, T-117 keymap, contrast-gate split, IPC hardening, panel-keyboard parity, etc.).
Source: consultants.md "Docs — Findings" item 3.', 'done', 'medium', NULL, NULL, NULL, '2026-05-18 08:07:23', '2026-05-18 08:22:19', NULL, '6f9f48da5d95074af1abed3df64b109a', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-115', 'story', NULL, 'startup project picker; "always open this project" override', 'Startup flow change: on launch, always show the project picker screen unless the user has explicitly checked an "always open this project" box for a specific project (sticky default).
**Acceptance:**
1. Default startup: project picker is the first screen, regardless of last-opened state.
2. Picker has an "always open this project on launch" checkbox per project entry.
3. If exactly one project has that flag set, startup skips the picker and opens that project directly.
4. The flag is per-project, persisted in app settings (probably under `app.startupProject`).
5. The checkbox can be unticked from the welcome view to restore picker-first behavior.
**Notes:** today the app auto-opens `app.lastProject` on boot (kernel/src/project.dart `openLast`). The new flow inverts the default picker is the steady state, sticky-open is opt-in per project.
**Out of scope:** multi-project workspaces, project groups, recent-project ordering changes.
**Source:** user request 2026-05-17.', 'done', 'medium', NULL, NULL, NULL, '2026-05-17 19:10:21', '2026-05-18 08:26:05', NULL, 'dc53a7ac8809206c2824d316750ec28a', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+6
View File
@@ -18,6 +18,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Added
- Startup project picker — clide now opens to the welcome screen by
default instead of auto-opening the last project. A per-row
"always open this project on launch" checkbox in welcome's RECENT
list sets a sticky-startup flag; if exactly one project has it,
that one opens directly. Two or more, or none ⇒ picker (T-115).
### Changed
### Deprecated
+51 -2
View File
@@ -264,7 +264,13 @@ class _RecentColumn extends StatelessWidget {
if (recents.isEmpty)
const ClideText('No recent projects.', muted: true, fontSize: 14)
else
for (final r in recents) _RecentRow(project: r, tokens: tokens, onTap: () => _openRecent(r.path)),
for (final r in recents)
_RecentRow(
project: r,
tokens: tokens,
onTap: () => _openRecent(r.path),
onToggleSticky: () => kernel.project.setStickyStartup(r.path, !r.startupSticky),
),
],
);
},
@@ -279,10 +285,16 @@ class _RecentColumn extends StatelessWidget {
}
class _RecentRow extends StatelessWidget {
const _RecentRow({required this.project, required this.tokens, required this.onTap});
const _RecentRow({
required this.project,
required this.tokens,
required this.onTap,
required this.onToggleSticky,
});
final RecentProject project;
final SurfaceTokens tokens;
final VoidCallback onTap;
final VoidCallback onToggleSticky;
@override
Widget build(BuildContext context) {
@@ -318,6 +330,8 @@ class _RecentRow extends StatelessWidget {
],
),
),
_StickyToggle(sticky: project.startupSticky, tokens: tokens, onTap: onToggleSticky),
const SizedBox(width: 12),
ClideText(project.timeAgo, muted: true, fontSize: 13),
],
),
@@ -326,6 +340,41 @@ class _RecentRow extends StatelessWidget {
}
}
/// Sticky-startup checkbox shown on each recent-project row (T-115).
/// When exactly one row is checked, clide opens that project on next
/// launch instead of showing the picker. Tooltip explains the rule.
class _StickyToggle extends StatelessWidget {
const _StickyToggle({required this.sticky, required this.tokens, required this.onTap});
final bool sticky;
final SurfaceTokens tokens;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return ClideTooltip(
message: sticky ? 'Always open this project on launch (uncheck to restore picker)' : 'Always open this project on launch',
child: Semantics(
button: true,
checked: sticky,
label: 'always open this project on launch',
child: ClideTappable(
onTap: onTap,
builder: (context, hovered, _) => Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: sticky ? tokens.statusBarItemActiveBackground : null,
border: Border.all(color: hovered || sticky ? tokens.panelActiveBorder : tokens.globalBorder),
borderRadius: BorderRadius.circular(3),
),
child: sticky ? ClideIcon(PhosphorIcons.check, size: 12, color: tokens.buttonForeground) : null,
),
),
),
);
}
}
class _StatusLine extends StatelessWidget {
const _StatusLine({required this.tokens, required this.kernel});
final SurfaceTokens tokens;
+72 -3
View File
@@ -9,20 +9,46 @@ import 'package:clide/kernel/src/toolchain.dart';
import 'package:flutter/foundation.dart';
class RecentProject {
const RecentProject({required this.path, required this.name, this.branch, required this.lastOpened});
const RecentProject({
required this.path,
required this.name,
this.branch,
required this.lastOpened,
this.startupSticky = false,
});
final String path;
final String name;
final String? branch;
final DateTime lastOpened;
Map<String, dynamic> toJson() => {'path': path, 'name': name, 'branch': branch, 'lastOpened': lastOpened.toIso8601String()};
/// When set, this project is preferred on startup over the picker.
/// If exactly one recent has this flag, [ProjectManager.openStickyOrNothing]
/// opens it directly; otherwise the welcome screen takes over (T-115).
final bool startupSticky;
RecentProject copyWith({bool? startupSticky, DateTime? lastOpened, String? branch}) => RecentProject(
path: path,
name: name,
branch: branch ?? this.branch,
lastOpened: lastOpened ?? this.lastOpened,
startupSticky: startupSticky ?? this.startupSticky,
);
Map<String, dynamic> toJson() => {
'path': path,
'name': name,
'branch': branch,
'lastOpened': lastOpened.toIso8601String(),
if (startupSticky) 'startupSticky': true,
};
factory RecentProject.fromJson(Map<String, dynamic> json) => RecentProject(
path: json['path'] as String? ?? '',
name: json['name'] as String? ?? '',
branch: json['branch'] as String?,
lastOpened: DateTime.tryParse(json['lastOpened'] as String? ?? '') ?? DateTime.now(),
startupSticky: json['startupSticky'] as bool? ?? false,
);
String get relativePath {
@@ -104,8 +130,15 @@ class ProjectManager extends ChangeNotifier {
final branch = await _currentBranch(root);
final name = root.split('/').last;
// Preserve the sticky-startup flag across reopens — otherwise the
// user would have to re-toggle it every time they touched the
// project (T-115).
final wasSticky = isStickyStartup(root);
_recents.removeWhere((r) => r.path == root);
_recents.insert(0, RecentProject(path: root, name: name, branch: branch, lastOpened: DateTime.now()));
_recents.insert(
0,
RecentProject(path: root, name: name, branch: branch, lastOpened: DateTime.now(), startupSticky: wasSticky),
);
if (_recents.length > 10) _recents = _recents.sublist(0, 10);
await _settings.set<String>('app.recentProjects', jsonEncode(_recents.map((r) => r.toJson()).toList()));
@@ -122,6 +155,42 @@ class ProjectManager extends ChangeNotifier {
return open(last);
}
/// Path of the lone sticky-startup project, or null if zero or
/// multiple recents have the flag. Zero ⇒ show the picker; multiple
/// ⇒ ambiguous, also show the picker (T-115).
String? get stickyProjectPath {
final sticky = _recents.where((r) => r.startupSticky).toList();
return sticky.length == 1 ? sticky.first.path : null;
}
/// Open the single sticky-startup project, if any. Returns false on
/// "no unambiguous sticky" so the caller (main.dart) can fall through
/// to the welcome screen.
Future<bool> openStickyOrNothing() async {
final path = stickyProjectPath;
if (path == null) return false;
final dir = Directory(path);
if (!await dir.exists()) return false;
return open(path);
}
/// Toggle the per-project sticky-startup flag and persist. No-op if
/// the path isn't in recents (open the project first to land it
/// there).
Future<void> setStickyStartup(String path, bool value) async {
final idx = _recents.indexWhere((r) => r.path == path);
if (idx < 0) return;
if (_recents[idx].startupSticky == value) return;
_recents[idx] = _recents[idx].copyWith(startupSticky: value);
await _settings.set<String>('app.recentProjects', jsonEncode(_recents.map((r) => r.toJson()).toList()));
notifyListeners();
}
bool isStickyStartup(String path) {
final idx = _recents.indexWhere((r) => r.path == path);
return idx >= 0 && _recents[idx].startupSticky;
}
Future<void> close() async {
if (_current == null) return;
_current = null;
+4 -4
View File
@@ -156,10 +156,10 @@ Future<void> main() async {
if (!kIsWeb) {
await services.project.loadRecents();
var opened = await services.project.openLast();
if (!opened) {
opened = await services.project.open(Directory.current.path);
}
// T-115: picker-first. Auto-open only when exactly one recent has
// its sticky-startup flag set; otherwise the welcome tab (default
// workspace content) serves as the project picker.
final opened = await services.project.openStickyOrNothing();
if (opened) {
services.panels.activateTab(Slots.workspace, 'claude.primary');
}
Binary file not shown.