Skip to content

Commit 9734edb

Browse files
authored
Mobile: unified Files search, project picker icons, Work-tab PR status, last-used composer (#609)
iOS companion bundle: full-screen unified Files search (names + contents); project favicon in the root Projects affordance; Work-tab per-lane PR status via unified LanePrTag (ADE + branch-matched GitHub PRs, fork-aware); persisted last-used composer selection (model + access mode, OpenCode-local provider coercion); shared arrow send button; Droid new-chat allowlist. Includes regression tests + docs.
1 parent 37637da commit 9734edb

27 files changed

Lines changed: 1323 additions & 483 deletions

apps/desktop/src/shared/types/sync.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -466,8 +466,8 @@ export type SyncFileRequest =
466466
| { action: "createDirectory"; args: { workspaceId: string; path: string } }
467467
| { action: "rename"; args: { workspaceId: string; oldPath: string; newPath: string } }
468468
| { action: "deletePath"; args: { workspaceId: string; path: string } }
469-
| { action: "quickOpen"; args: { workspaceId: string; query: string; limit?: number } }
470-
| { action: "searchText"; args: { workspaceId: string; query: string; limit?: number } }
469+
| { action: "quickOpen"; args: { workspaceId: string; query: string; limit?: number; includeIgnored?: boolean } }
470+
| { action: "searchText"; args: { workspaceId: string; query: string; limit?: number; includeIgnored?: boolean } }
471471
| { action: "readArtifact"; args: { artifactId?: string; uri?: string; path?: string } };
472472

473473
export type SyncFileResponsePayload = {

apps/ios/ADE.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107
E2000000000000000000004B /* FilesDetailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2000000000000000000004B /* FilesDetailScreen.swift */; };
108108
E2000000000000000000004C /* FilesDetailScreen+Actions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2000000000000000000004C /* FilesDetailScreen+Actions.swift */; };
109109
E2000000000000000000004D /* FilesWorkspacePickerDropdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2000000000000000000004D /* FilesWorkspacePickerDropdown.swift */; };
110+
E2000000000000000000004E /* FilesSearchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2000000000000000000004E /* FilesSearchScreen.swift */; };
110111
60F4CDDB763C0A9F0E650B40 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 31EC445F22FD38F90C16343E /* Foundation.framework */; };
111112
63A9C60B0E0F0E2707634B2E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8943C47805A871A4E4A4BF68 /* Assets.xcassets */; };
112113
6BDC22C6450AF0B3CBDB2650 /* FilesTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EBFB65019F4C3F8A89428CE /* FilesTabView.swift */; };
@@ -322,6 +323,7 @@
322323
D2000000000000000000004B /* FilesDetailScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FilesDetailScreen.swift; path = ADE/Views/Files/FilesDetailScreen.swift; sourceTree = "<group>"; };
323324
D2000000000000000000004C /* FilesDetailScreen+Actions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "FilesDetailScreen+Actions.swift"; path = "ADE/Views/Files/FilesDetailScreen+Actions.swift"; sourceTree = "<group>"; };
324325
D2000000000000000000004D /* FilesWorkspacePickerDropdown.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FilesWorkspacePickerDropdown.swift; path = ADE/Views/Files/FilesWorkspacePickerDropdown.swift; sourceTree = "<group>"; };
326+
D2000000000000000000004E /* FilesSearchScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FilesSearchScreen.swift; path = ADE/Views/Files/FilesSearchScreen.swift; sourceTree = "<group>"; };
325327
14C0DF7FEB4C2EB854BAC888 /* ADETests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADETests.swift; path = ADETests/ADETests.swift; sourceTree = "<group>"; };
326328
D30000000000000000000001 /* AttentionDrawerModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AttentionDrawerModel.swift; path = ADE/Views/AttentionDrawer/AttentionDrawerModel.swift; sourceTree = "<group>"; };
327329
D30000000000000000000002 /* AttentionDrawerButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AttentionDrawerButton.swift; path = ADE/Views/AttentionDrawer/AttentionDrawerButton.swift; sourceTree = "<group>"; };
@@ -585,6 +587,7 @@
585587
D2000000000000000000004B /* FilesDetailScreen.swift */,
586588
D2000000000000000000004C /* FilesDetailScreen+Actions.swift */,
587589
D2000000000000000000004D /* FilesWorkspacePickerDropdown.swift */,
590+
D2000000000000000000004E /* FilesSearchScreen.swift */,
588591
);
589592
name = Files;
590593
sourceTree = "<group>";
@@ -1029,6 +1032,7 @@
10291032
E2000000000000000000004B /* FilesDetailScreen.swift in Sources */,
10301033
E2000000000000000000004C /* FilesDetailScreen+Actions.swift in Sources */,
10311034
E2000000000000000000004D /* FilesWorkspacePickerDropdown.swift in Sources */,
1035+
E2000000000000000000004E /* FilesSearchScreen.swift in Sources */,
10321036
B10000000000000000000002 /* LaneAttachSheet.swift in Sources */,
10331037
B10000000000000000000003 /* LaneBatchManageSheet.swift in Sources */,
10341038
B10000000000000000000004 /* LaneChatLaunchSheet.swift in Sources */,

apps/ios/ADE/App/ContentView.swift

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -447,13 +447,8 @@ private struct ProjectHomeIcon: View {
447447
RoundedRectangle(cornerRadius: 7, style: .continuous)
448448
.fill(isActive ? ADEColor.accent.opacity(0.16) : ADEColor.recessedBackground)
449449
.frame(width: 38, height: 38)
450-
if let image = projectHomeIconImage(from: iconDataUrl) {
451-
Image(uiImage: image)
452-
.resizable()
453-
.interpolation(.high)
454-
.aspectRatio(contentMode: .fit)
455-
.frame(width: 24, height: 24)
456-
.clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous))
450+
if let image = projectIconImage(from: iconDataUrl) {
451+
Image(uiImage: image).projectIconStyle(size: 24, cornerRadius: 4)
457452
} else {
458453
Image(systemName: "folder")
459454
.font(.system(size: 16, weight: .semibold))
@@ -463,23 +458,6 @@ private struct ProjectHomeIcon: View {
463458
}
464459
}
465460

466-
private let projectHomeIconImageCache = NSCache<NSString, UIImage>()
467-
468-
private func projectHomeIconImage(from dataUrl: String?) -> UIImage? {
469-
guard let dataUrl, !dataUrl.isEmpty else { return nil }
470-
let cacheKey = dataUrl as NSString
471-
if let cached = projectHomeIconImageCache.object(forKey: cacheKey) {
472-
return cached
473-
}
474-
guard let commaIndex = dataUrl.firstIndex(of: ",") else { return nil }
475-
let base64 = String(dataUrl[dataUrl.index(after: commaIndex)...])
476-
guard let data = Data(base64Encoded: base64),
477-
let image = UIImage(data: data)
478-
else { return nil }
479-
projectHomeIconImageCache.setObject(image, forKey: cacheKey)
480-
return image
481-
}
482-
483461
private struct ProjectHomeRow: View {
484462
let project: MobileProjectSummary
485463
let isActive: Bool

apps/ios/ADE/Models/RemoteModels.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3167,6 +3167,11 @@ struct GitHubPrListItem: Codable, Identifiable, Equatable {
31673167
var isDraft: Bool
31683168
var baseBranch: String?
31693169
var headBranch: String?
3170+
/// Owner/name of the PR's head repository. Differs from `repoOwner`/`repoName`
3171+
/// for fork PRs; used to reject a fork PR whose head branch name coincides with
3172+
/// a local lane branch. Nil against older hosts that don't send these fields.
3173+
var headRepoOwner: String? = nil
3174+
var headRepoName: String? = nil
31703175
var author: String?
31713176
var createdAt: String
31723177
var updatedAt: String

apps/ios/ADE/Services/SyncService.swift

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1193,6 +1193,14 @@ final class SyncService: ObservableObject {
11931193
/// re-fetches the sidecar fan-out.
11941194
@Published private(set) var prDetailCache: [String: PrDetailWarmEntry] = [:]
11951195

1196+
/// Repo-scoped GitHub PR list shared by the Lanes and Work tabs so a lane (a
1197+
/// branch) can surface a PR opened directly on GitHub — not only PRs mapped
1198+
/// into the synced `pull_requests` table. Populated best-effort by
1199+
/// `refreshLaneGithubPrItems()`; empty when offline or not yet fetched, in
1200+
/// which case the lane-PR chip falls back to the ADE-mapped rows alone.
1201+
@Published private(set) var laneGithubPrItems: [GitHubPrListItem] = []
1202+
private var laneGithubPrItemsFetchedAt: Date?
1203+
11961204
var connectionHealth: SyncConnectionHealth {
11971205
syncConnectionHealth(
11981206
connectionState: connectionState,
@@ -3795,6 +3803,36 @@ final class SyncService: ObservableObject {
37953803
)
37963804
}
37973805

3806+
/// Best-effort refresh of the shared `laneGithubPrItems` cache used by the
3807+
/// Lanes and Work tabs to tag lanes with GitHub PRs opened outside ADE.
3808+
/// Throttled (skips when refreshed within `minInterval` unless `force`), a
3809+
/// no-op when the transport is down, and never throws — a missing GitHub
3810+
/// snapshot just leaves the ADE-mapped fallback in place.
3811+
func refreshLaneGithubPrItems(force: Bool = false, minInterval: TimeInterval = 20) async {
3812+
guard connectionState == .connected || connectionState == .syncing else { return }
3813+
if !force, let fetchedAt = laneGithubPrItemsFetchedAt,
3814+
Date().timeIntervalSince(fetchedAt) < minInterval
3815+
{
3816+
return
3817+
}
3818+
// Scope the write to the project in effect when the fetch starts. A project
3819+
// switch (which calls resetChatEventState and clears this cache) can land
3820+
// while the snapshot request is in flight; without this guard a late
3821+
// response from the prior project would repopulate the cache with another
3822+
// repo's PRs.
3823+
let requestedProjectId = activeProjectId
3824+
do {
3825+
let snapshot = try await fetchGitHubPullRequestSnapshot(force: force)
3826+
guard connectionState == .connected || connectionState == .syncing,
3827+
activeProjectId == requestedProjectId
3828+
else { return }
3829+
laneGithubPrItems = snapshot.repoPullRequests.filter { $0.scope == "repo" }
3830+
laneGithubPrItemsFetchedAt = Date()
3831+
} catch {
3832+
// Leave the previous cache (and the ADE-mapped fallback) in place.
3833+
}
3834+
}
3835+
37983836
func fetchPullRequestReviewThreads(prId: String) async throws -> [PrReviewThread] {
37993837
try await sendDecodableCommand(action: "prs.getReviewThreads", args: ["prId": prId], as: [PrReviewThread].self)
38003838
}
@@ -4048,19 +4086,36 @@ final class SyncService: ObservableObject {
40484086
])
40494087
}
40504088

4051-
func quickOpen(workspaceId: String, query: String) async throws -> [FilesQuickOpenItem] {
4052-
try decode(
4053-
try await sendFileRequest(action: "quickOpen", args: ["workspaceId": workspaceId, "query": query]),
4089+
func quickOpen(
4090+
workspaceId: String,
4091+
query: String,
4092+
limit: Int = 30,
4093+
includeIgnored: Bool = true
4094+
) async throws -> [FilesQuickOpenItem] {
4095+
let boundedLimit = min(max(limit, 1), 1000)
4096+
return try decode(
4097+
try await sendFileRequest(action: "quickOpen", args: [
4098+
"workspaceId": workspaceId,
4099+
"query": query,
4100+
"limit": boundedLimit,
4101+
"includeIgnored": includeIgnored,
4102+
]),
40544103
as: [FilesQuickOpenItem].self
40554104
)
40564105
}
40574106

4058-
func searchText(workspaceId: String, query: String, includeIgnored: Bool = true) async throws -> [FilesSearchTextMatch] {
4059-
try decode(
4107+
func searchText(
4108+
workspaceId: String,
4109+
query: String,
4110+
limit: Int = 300,
4111+
includeIgnored: Bool = true
4112+
) async throws -> [FilesSearchTextMatch] {
4113+
let boundedLimit = min(max(limit, 1), 1000)
4114+
return try decode(
40604115
try await sendFileRequest(action: "searchText", args: [
40614116
"workspaceId": workspaceId,
40624117
"query": query,
4063-
"limit": 200,
4118+
"limit": boundedLimit,
40644119
"includeIgnored": includeIgnored,
40654120
]),
40664121
as: [FilesSearchTextMatch].self
@@ -8741,6 +8796,11 @@ final class SyncService: ObservableObject {
87418796
}
87428797

87438798
private func resetChatEventState(clearHistory: Bool) {
8799+
// GitHub PR items are repo-scoped to the active project; drop them so a
8800+
// project switch / reconnect re-fetches against the new repo instead of
8801+
// tagging lanes with another project's PRs.
8802+
laneGithubPrItems = []
8803+
laneGithubPrItemsFetchedAt = nil
87448804
subscribedChatSessionIds.removeAll()
87458805
// Turn-active hints are scoped to the live connection's event stream —
87468806
// a stale "running" hint must not survive a project switch or reconnect.

apps/ios/ADE/Views/Components/ADEDesignSystem.swift

Lines changed: 108 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,45 @@ struct ADEConnectionDot: View {
651651
fileprivate var a11yLabel: String { "Machine connection · \(accessibilityLabel)" }
652652
}
653653

654+
private let projectIconImageCache = NSCache<NSString, UIImage>()
655+
656+
/// Decodes a base64 `data:` URL project icon into a `UIImage`, memoised in a
657+
/// process-wide cache. Returns nil when the project has no icon. Shared by the
658+
/// project-home list rows and the root toolbar's projects affordance so both
659+
/// resolve icons through one cache.
660+
func projectIconImage(from dataUrl: String?) -> UIImage? {
661+
guard let dataUrl, !dataUrl.isEmpty else { return nil }
662+
let cacheKey = dataUrl as NSString
663+
if let cached = projectIconImageCache.object(forKey: cacheKey) {
664+
return cached
665+
}
666+
guard let commaIndex = dataUrl.firstIndex(of: ",") else { return nil }
667+
let base64 = String(dataUrl[dataUrl.index(after: commaIndex)...])
668+
// Project icons are pre-rendered host-side to a 64×64 PNG (a few KB), so a
669+
// payload past ~768 KB of base64 is malformed or hostile — reject it rather
670+
// than drive a large allocation/decode on the UI path.
671+
guard base64.count <= 1_048_576,
672+
let data = Data(base64Encoded: base64),
673+
let image = UIImage(data: data)
674+
else { return nil }
675+
projectIconImageCache.setObject(image, forKey: cacheKey)
676+
return image
677+
}
678+
679+
extension Image {
680+
/// Shared presentation for a decoded project icon: high-quality fit inside a
681+
/// rounded square. Size and corner radius vary per surface (toolbar capsule,
682+
/// leading disc, project-home rows), so they stay parameters.
683+
func projectIconStyle(size: CGFloat, cornerRadius: CGFloat) -> some View {
684+
self
685+
.resizable()
686+
.interpolation(.high)
687+
.aspectRatio(contentMode: .fit)
688+
.frame(width: size, height: size)
689+
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
690+
}
691+
}
692+
654693
struct ADEProjectHomeButton: View {
655694
@EnvironmentObject private var syncService: SyncService
656695

@@ -660,9 +699,14 @@ struct ADEProjectHomeButton: View {
660699
Text("Projects")
661700
} icon: {
662701
PrsGlassDisc(tint: PrsGlass.glowPurple, isAlive: true) {
663-
Image(systemName: "square.grid.2x2.fill")
664-
.font(.system(size: 13, weight: .semibold))
665-
.foregroundStyle(PrsGlass.accentTop)
702+
if let icon = projectIconImage(from: syncService.activeProject?.iconDataUrl) {
703+
// Detected project logo replaces the generic grid glyph.
704+
Image(uiImage: icon).projectIconStyle(size: 20, cornerRadius: 5)
705+
} else {
706+
Image(systemName: "square.grid.2x2.fill")
707+
.font(.system(size: 13, weight: .semibold))
708+
.foregroundStyle(PrsGlass.accentTop)
709+
}
666710
}
667711
}
668712
.labelStyle(.iconOnly)
@@ -742,6 +786,7 @@ struct ADERootToolbarControls: View {
742786
icon: "square.grid.2x2.fill",
743787
tint: PrsGlass.accentTop,
744788
isAlive: false,
789+
iconImage: projectIconImage(from: syncService.activeProject?.iconDataUrl),
745790
accessibilityLabel: "Projects",
746791
action: { syncService.showProjectHome() }
747792
)
@@ -823,6 +868,7 @@ struct ADERootToolbarControls: View {
823868
icon: String,
824869
tint: Color,
825870
isAlive: Bool,
871+
iconImage: UIImage? = nil,
826872
accessibilityLabel: String,
827873
action: @escaping () -> Void
828874
) -> some View {
@@ -834,10 +880,15 @@ struct ADERootToolbarControls: View {
834880
.frame(width: 24, height: 24)
835881
.blur(radius: 3)
836882
}
837-
Image(systemName: icon)
838-
.font(.system(size: 14, weight: .semibold))
839-
.foregroundStyle(tint)
840-
.shadow(color: isAlive ? tint.opacity(0.28) : .clear, radius: 2, x: 0, y: 0)
883+
if let iconImage {
884+
// Detected project logo replaces the generic grid glyph.
885+
Image(uiImage: iconImage).projectIconStyle(size: 22, cornerRadius: 5)
886+
} else {
887+
Image(systemName: icon)
888+
.font(.system(size: 14, weight: .semibold))
889+
.foregroundStyle(tint)
890+
.shadow(color: isAlive ? tint.opacity(0.28) : .clear, radius: 2, x: 0, y: 0)
891+
}
841892
}
842893
.frame(width: 38, height: 34)
843894
.contentShape(Rectangle())
@@ -958,6 +1009,56 @@ struct ADERootToolbarLeadingItems: ToolbarContent {
9581009
}
9591010
}
9601011

1012+
/// Canonical chat-composer send affordance: a compact circular button with an
1013+
/// upward arrow, matching the desktop composer (phosphor `ArrowUp` in a white
1014+
/// disc). Shared by the New Chat composer and the in-session chat composer so
1015+
/// every prompt box sends with the same glyph instead of mismatched paperplanes.
1016+
struct ADEComposerSendButton: View {
1017+
/// Whether there is sendable input. Drives the filled vs. recessed treatment.
1018+
let enabled: Bool
1019+
/// While a send is in flight, swaps the arrow for an inline spinner.
1020+
let sending: Bool
1021+
var accessibilityLabelText: String = "Send message"
1022+
/// Optional VoiceOver label used while disabled (e.g. "Enter a message to
1023+
/// send") so users hear *why* the button is unavailable. Falls back to
1024+
/// `accessibilityLabelText` when nil.
1025+
var disabledAccessibilityLabel: String? = nil
1026+
let action: () -> Void
1027+
1028+
/// Dark glyph color on the light disc (matches the in-session composer).
1029+
private let glyphColor = Color(red: 0.12, green: 0.12, blue: 0.14)
1030+
1031+
private var resolvedAccessibilityLabel: String {
1032+
if sending { return "Sending message" }
1033+
if !enabled, let disabledAccessibilityLabel { return disabledAccessibilityLabel }
1034+
return accessibilityLabelText
1035+
}
1036+
1037+
var body: some View {
1038+
Button(action: action) {
1039+
ZStack {
1040+
if sending {
1041+
ProgressView()
1042+
.controlSize(.mini)
1043+
.tint(enabled ? glyphColor : ADEColor.textSecondary)
1044+
} else {
1045+
Image(systemName: "arrow.up")
1046+
.font(.system(size: 14, weight: .bold))
1047+
}
1048+
}
1049+
.frame(width: 28, height: 28)
1050+
.foregroundStyle(enabled ? glyphColor : ADEColor.textSecondary.opacity(0.2))
1051+
.background(
1052+
Circle()
1053+
.fill(enabled ? Color.white.opacity(0.9) : Color.white.opacity(0.06))
1054+
)
1055+
}
1056+
.buttonStyle(.plain)
1057+
.disabled(!enabled)
1058+
.accessibilityLabel(resolvedAccessibilityLabel)
1059+
}
1060+
}
1061+
9611062
struct ADEEmptyStateView<Actions: View>: View {
9621063
let symbol: String
9631064
let title: String

0 commit comments

Comments
 (0)