Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/mobile/app/(tabs)/user.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ export default function UserPage() {
</View>
<View className="min-w-0 flex-1">
<Text className="text-sm font-semibold">Free · 1 tunnel</Text>
<Text className="mt-0.5 text-xs text-muted-foreground">Upgrade for 2–5 parallel tunnels</Text>
<Text className="mt-0.5 text-xs text-muted-foreground">Upgrade for 2 to 5 parallel tunnels</Text>
</View>
<Button size="sm" className="rounded-full" onPress={() => requestPaywall("usage_nudge")}>
<Text>Upgrade</Text>
Expand Down
205 changes: 164 additions & 41 deletions apps/mobile/app/project/[projectId]/[sessionId]/index.tsx

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion apps/mobile/app/project/[projectId]/[sessionId]/models.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export default function ModelsPage() {
const fetchAll = useModels((s) => s.fetchAll)

const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState("active")
const [statusFilter, setStatusFilter] = useState("all")
const [sort, setSort] = useState<SortMode>("name")
const [filterOpen, setFilterOpen] = useState(false)
const [sortOpen, setSortOpen] = useState(false)
Expand Down
8 changes: 6 additions & 2 deletions apps/mobile/app/sessions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,9 @@ export default function SessionsScreen() {
}, [connection?.id, project?.id, fetchSessions])

const sessionSections = React.useMemo<SessionSection[]>(() => {
let result = sessions.filter((s) => s.directory === project?.directory)
const dirOf = (s: Session) =>
s.directory ?? (s as unknown as { location?: { directory?: string } }).location?.directory ?? ""
let result = sessions.filter((s) => (project?.directory ? dirOf(s) === project.directory : true))

if (searchQuery.trim()) {
const q = searchQuery.toLowerCase()
Expand Down Expand Up @@ -286,7 +288,9 @@ export default function SessionsScreen() {

const visibleSessionCount = sessionSections.reduce((count, section) => count + section.data.length, 0)
const categoryCounts = React.useMemo(() => {
const projectSessions = sessions.filter((s) => s.directory === project?.directory)
const dirOf = (s: Session) =>
s.directory ?? (s as unknown as { location?: { directory?: string } }).location?.directory ?? ""
const projectSessions = sessions.filter((s) => (project?.directory ? dirOf(s) === project.directory : true))
return {
all: projectSessions.length,
agents: projectSessions.filter((s) => getSessionCategory(s) === "agents").length,
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/components/TunnelUsageCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ export function TunnelUsageCard() {
>
<Text className="text-sm font-semibold text-primary">
{stats.totalClients >= 1
? "At your Free limit — Pro gives 5 tunnels + hosted relay"
? "At your Free limit. Pro gives 5 tunnels + hosted relay"
: "Need more tunnels? Upgrade to Pro"}
</Text>
</Button>
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/components/chat-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,7 @@ function ChatInputInner({

{isQueueMode ? (
<Text className="text-[11px] px-3 pb-1.5 text-amber-600 dark:text-amber-400">
Queues after the current response — clear the input to stop instead.
Queues after the current response. Clear the input to stop instead.
</Text>
) : null}

Expand Down
6 changes: 3 additions & 3 deletions apps/mobile/components/hooks/event-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ export function useEventStream(url?: string, sessionId?: string, token?: string,
let pendingMessages: Message[] | null = null
// Phone-local file:// uris salvaged from optimistic messages. Server echoes
// user images back as data: URLs or server-local paths which often fail to
// render on device — replay the local uri instead, exactly like chat input.
// render on device. Replay the local uri instead, exactly like chat input.
let localFileUriBackup: string[] = []
let flushScheduled = false
const countedMessageIds = new Set<string>()
Expand Down Expand Up @@ -231,7 +231,7 @@ export function useEventStream(url?: string, sessionId?: string, token?: string,
// server-local paths), while the optimistic message holds
// the renderable file:// uri. The old code merged first
// (backup still empty, so server urls won) and only
// patched things up afterwards — leaving blank image boxes
// patched things up afterwards, leaving blank image boxes
// whenever the patch-up missed.
let salvagedLocalFileUrls: string[] = []
if (info.role === "user") {
Expand Down Expand Up @@ -658,7 +658,7 @@ export function useEventStream(url?: string, sessionId?: string, token?: string,
// Only show reconnecting/connectivity-issues after repeated failures
// to avoid banner flicker on brief, self-healing stream drops
if (reconnectAttempts <= 2) {
// Keep "connected" — the stream may recover on its own
// Keep "connected". The stream may recover on its own
} else if (reconnectAttempts <= 4) {
setConnectionStatus("reconnecting")
} else {
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/components/mention-autocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ function MentionAutocompleteInner({ visible, loading, agents, files, onSelect, t
</View>
) : (
<Text className="text-xs text-muted-foreground text-center py-4">
No matches — keep typing or pick a subagent
No matches. Keep typing or pick a subagent
</Text>
)
}
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/components/message-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ function getErrorLabel(name?: string): string {
function normalizeImageUri(uri: string, mime?: string): string {
if (!uri) return uri
if (/^(data:|file:\/\/|content:\/\/|ph:\/\/|asset:\/\/|http:\/\/|https:\/\/)/.test(uri)) return uri
// Bare base64 payload (some servers strip the data: prefix) — rebuild it.
// Bare base64 payload (some servers strip the data: prefix). Rebuild it.
if (mime?.startsWith("image/") && /^[A-Za-z0-9+/=\s]+$/.test(uri) && uri.length > 100) {
return `data:${mime};base64,${uri.replace(/\s/g, "")}`
}
Expand Down Expand Up @@ -816,7 +816,7 @@ function MessageItemInner({ message, theme, projectId, sessionId, pendingQuestio
<View className="flex-row items-center gap-1 mb-0.5">
<View className="px-2 py-0.5 rounded-full bg-amber-500/15 border border-amber-500/30">
<Text className="text-[10px] font-medium text-amber-600 dark:text-amber-400">
Queued — sends after current response
Queued: sends after current response
</Text>
</View>
</View>
Expand Down
47 changes: 32 additions & 15 deletions apps/mobile/lib/diff.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getAuthHeader } from "@/lib/utils"
import { apiUrl, getAuthHeader, unwrapList } from "@/lib/utils"
import { getServerVersion } from "@/lib/server-version"

export type FileDiff = {
file: string
Expand Down Expand Up @@ -31,21 +32,37 @@ function normalizeFileDiff(value: unknown): FileDiff | null {

export async function fetchSessionDiffs(url: string, token: string, sessionId: string, messageID?: string): Promise<FileDiff[]> {
try {
const version = getServerVersion(url)
const query = messageID ? `?messageID=${encodeURIComponent(messageID)}` : ""
const res = await fetch(`${url}/session/${sessionId}/diff${query}`, {
method: "GET",
headers: {
"Authorization": getAuthHeader(token),
},
})
if (!res.ok) return []
const data = await res.json()
const records = Array.isArray(data)
? data
: data && typeof data === "object" && Array.isArray((data as Record<string, unknown>).diffs)
? (data as { diffs: unknown[] }).diffs
: []
return records.map(normalizeFileDiff).filter((diff): diff is FileDiff => diff !== null)
// v2 moved diffs under the session revert/vcs surface; try legacy first.
const endpoints = [
`${apiUrl(url, version, "/session")}/${sessionId}/diff${query}`,
`${apiUrl(url, version >= 2 ? 1 : 2, "/session")}/${sessionId}/diff${query}`,
]
for (const endpoint of endpoints) {
try {
const res = await fetch(endpoint, {
method: "GET",
headers: {
"Authorization": getAuthHeader(token),
},
})
if (!res.ok) continue
const data = await res.json()
const records = unwrapList<unknown>(data).length > 0
? unwrapList<unknown>(data)
: Array.isArray(data)
? data
: data && typeof data === "object" && Array.isArray((data as Record<string, unknown>).diffs)
? (data as { diffs: unknown[] }).diffs
: []
const diffs = (records as unknown[]).map(normalizeFileDiff).filter((diff): diff is FileDiff => diff !== null)
if (diffs.length > 0) return diffs
} catch {
continue
}
}
return []
} catch {
return []
}
Expand Down
192 changes: 137 additions & 55 deletions apps/mobile/lib/file-browser.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getAuthHeader } from "@/lib/utils"
import { apiUrl, getAuthHeader, unwrapList } from "@/lib/utils"
import { getServerVersion } from "@/lib/server-version"
import { normalizeLanguage } from "@/lib/prism"

export type FileEntryType = "file" | "directory"
Expand All @@ -17,7 +18,12 @@ const MAX_CONTENT_BYTES = 200 * 1024
function normalizeFileEntry(value: unknown): FileEntry | null {
if (!value || typeof value !== "object") return null
const entry = value as Record<string, unknown>
const path = typeof entry.path === "string" ? entry.path : typeof entry.name === "string" ? entry.name : undefined
const path =
typeof entry.path === "string"
? entry.path
: typeof entry.name === "string"
? entry.name
: undefined
if (!path) return null
const rawType = typeof entry.type === "string" ? entry.type : ""
const type: FileEntryType = rawType === "directory" ? "directory" : rawType === "file" ? "file" : path.endsWith("/") ? "directory" : "file"
Expand All @@ -35,26 +41,65 @@ export function sortEntries(entries: FileEntry[]): FileEntry[] {
}

export async function listDirectory(url: string, token: string, path?: string): Promise<FileEntry[]> {
try {
const query = `?path=${encodeURIComponent(path || ".")}`
const res = await fetch(`${url}/file${query}`, {
method: "GET",
headers: {
"Authorization": getAuthHeader(token),
},
})
if (!res.ok) throw new Error(`Failed to list directory (${res.status})`)
const data = await res.json()
if (!Array.isArray(data)) throw new Error("Unexpected directory listing response")
const entries = data.map(normalizeFileEntry).filter((entry): entry is FileEntry => entry !== null)
return sortEntries(entries)
} catch (err) {
throw err instanceof Error ? err : new Error("Failed to list directory")
const version = getServerVersion(url)
const dir = path || "."
// v2 canonical: GET /api/fs/list?path= ; v1: GET /file?path=
const endpoints =
version >= 2
? [`${apiUrl(url, 2, "/fs/list")}?path=${encodeURIComponent(dir)}`, `${apiUrl(url, 1, "/file")}?path=${encodeURIComponent(dir)}`]
: [`${apiUrl(url, 1, "/file")}?path=${encodeURIComponent(dir)}`, `${apiUrl(url, 2, "/fs/list")}?path=${encodeURIComponent(dir)}`]
let lastError: unknown = null
for (const endpoint of endpoints) {
try {
const res = await fetch(endpoint, {
method: "GET",
headers: {
"Authorization": getAuthHeader(token),
},
})
if (!res.ok) {
lastError = new Error(`Failed to list directory (${res.status})`)
continue
}
const data = await res.json()
const list = unwrapList<unknown>(data)
if (!Array.isArray(list)) throw new Error("Unexpected directory listing response")
const entries = list.map(normalizeFileEntry).filter((entry): entry is FileEntry => entry !== null)
return sortEntries(entries)
} catch (err) {
lastError = err
}
}
throw lastError instanceof Error ? lastError : new Error("Failed to list directory")
}

export async function readFileContent(url: string, token: string, path: string): Promise<string> {
const res = await fetch(`${url}/file/content?path=${encodeURIComponent(path)}`, {
const version = getServerVersion(url)
// v2 serves raw bytes at /api/fs/read/*; legacy JSON at /file/content.
if (version >= 2) {
try {
const res = await fetch(`${apiUrl(url, 2, "/fs/read")}/${path.split("/").map(encodeURIComponent).join("/")}`, {
method: "GET",
headers: { "Authorization": getAuthHeader(token) },
})
if (res.ok) {
const text = await res.text()
// The raw endpoint may still return JSON { content } on some builds.
try {
const data = JSON.parse(text) as { content?: unknown; text?: unknown; data?: unknown }
if (typeof data.content === "string") return data.content.slice(0, MAX_CONTENT_BYTES)
if (typeof data.text === "string") return data.text.slice(0, MAX_CONTENT_BYTES)
if (typeof data.data === "string") return data.data.slice(0, MAX_CONTENT_BYTES)
} catch {
// Not JSON, treat as raw text.
}
return text.length > MAX_CONTENT_BYTES ? text.slice(0, MAX_CONTENT_BYTES) : text
}
} catch {
// Fall through to the legacy endpoint.
}
}
const res = await fetch(`${apiUrl(url, 1, "/file/content")}?path=${encodeURIComponent(path)}`, {
method: "GET",
headers: {
"Authorization": getAuthHeader(token),
Expand All @@ -75,50 +120,87 @@ export async function readFileContent(url: string, token: string, path: string):
}

export async function searchFiles(url: string, token: string, query: string): Promise<string[]> {
try {
const res = await fetch(`${url}/find/file?query=${encodeURIComponent(query)}`, {
method: "GET",
headers: {
"Authorization": getAuthHeader(token),
},
})
if (!res.ok) return []
const data = await res.json()
if (!Array.isArray(data)) return []
return data.filter((item): item is string => typeof item === "string")
} catch {
return []
const version = getServerVersion(url)
const endpoints =
version >= 2
? [`${apiUrl(url, 2, "/fs/find")}?query=${encodeURIComponent(query)}`, `${apiUrl(url, 1, "/find/file")}?query=${encodeURIComponent(query)}`]
: [`${apiUrl(url, 1, "/find/file")}?query=${encodeURIComponent(query)}`, `${apiUrl(url, 2, "/fs/find")}?query=${encodeURIComponent(query)}`]
for (const endpoint of endpoints) {
try {
const res = await fetch(endpoint, {
method: "GET",
headers: {
"Authorization": getAuthHeader(token),
},
})
if (!res.ok) continue
const data = await res.json()
const list = unwrapList<unknown>(data)
if (!Array.isArray(list)) continue
// v2 returns Entry objects; v1 returns plain path strings.
const paths = list
.map((item) =>
typeof item === "string"
? item
: item && typeof item === "object" && typeof (item as Record<string, unknown>).path === "string"
? ((item as Record<string, unknown>).path as string)
: null
)
.filter((item): item is string => typeof item === "string")
if (paths.length > 0 || version >= 2) return paths
return paths
} catch {
continue
}
}
return []
}

export async function fetchFileStatuses(url: string, token: string): Promise<FileStatuses> {
try {
const res = await fetch(`${url}/file/status`, {
method: "GET",
headers: {
"Authorization": getAuthHeader(token),
},
})
if (!res.ok) return {}
const data = await res.json()
if (!data || typeof data !== "object") return {}

const records: Array<[string, unknown]> = Array.isArray(data)
? data.filter((item) => item && typeof item === "object" && typeof (item as Record<string, unknown>).path === "string")
.map((item) => [(item as Record<string, unknown>).path as string, item])
: Object.entries(data as Record<string, unknown>)

const result: FileStatuses = {}
for (const [key, value] of records) {
let status: unknown = value
if (value && typeof value === "object") {
status = (value as Record<string, unknown>).status
}
if (status === "added" || status === "modified" || status === "deleted") {
result[key] = status
// v2 has no direct file-status route on some builds; try legacy first,
// then the v2 fs status alias when present.
const version = getServerVersion(url)
const endpoints =
version >= 2
? [apiUrl(url, 1, "/file/status"), apiUrl(url, 2, "/fs/status")]
: [apiUrl(url, 1, "/file/status")]
for (const endpoint of endpoints) {
try {
const res = await fetch(endpoint, {
method: "GET",
headers: {
"Authorization": getAuthHeader(token),
},
})
if (!res.ok) continue
const data = await res.json()
const unwrapped = unwrapList<unknown>(data).length > 0 ? unwrapList<unknown>(data) : data
if (!unwrapped || typeof unwrapped !== "object") continue

const records: Array<[string, unknown]> = Array.isArray(unwrapped)
? (unwrapped as unknown[])
.filter((item) => item && typeof item === "object" && typeof (item as Record<string, unknown>).path === "string")
.map((item) => [(item as Record<string, unknown>).path as string, item])
: Object.entries(unwrapped as Record<string, unknown>)

const result: FileStatuses = {}
for (const [key, value] of records) {
let status: unknown = value
if (value && typeof value === "object") {
const v = value as Record<string, unknown>
status = v.status ?? v.state ?? v.kind
}
if (status === "added" || status === "modified" || status === "deleted") {
result[key] = status
}
}
return result
} catch {
continue
}
}
return result
return {}
} catch {
return {}
}
Expand Down
Loading
Loading